]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/lib/activitypub/videos.ts
Process remaining segment hashes on live ending
[github/Chocobozzz/PeerTube.git] / server / lib / activitypub / videos.ts
index d182ca5a28e41df77dedf6bdf89c4676122d931a..4053f487cf74c6d6e414026aa2f95d6031265f08 100644 (file)
@@ -1,60 +1,49 @@
 import * as Bluebird from 'bluebird'
-import * as sequelize from 'sequelize'
+import { maxBy, minBy } from 'lodash'
 import * as magnetUtil from 'magnet-uri'
+import { join } from 'path'
 import * as request from 'request'
+import * as sequelize from 'sequelize'
+import { VideoLiveModel } from '@server/models/video/video-live'
 import {
   ActivityHashTagObject,
   ActivityMagnetUrlObject,
   ActivityPlaylistSegmentHashesObject,
   ActivityPlaylistUrlObject,
+  ActivitypubHttpFetcherPayload,
   ActivityTagObject,
   ActivityUrlObject,
-  ActivityVideoUrlObject,
-  VideoState
+  ActivityVideoUrlObject
 } from '../../../shared/index'
-import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
+import { VideoObject } from '../../../shared/models/activitypub/objects'
 import { VideoPrivacy } from '../../../shared/models/videos'
-import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
+import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
+import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
+import { buildRemoteVideoBaseUrl, checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
+import { isAPVideoFileMetadataObject, sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
+import { isArray } from '../../helpers/custom-validators/misc'
 import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
 import { deleteNonExistingModels, resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
 import { logger } from '../../helpers/logger'
 import { doRequest } from '../../helpers/requests'
+import { fetchVideoByUrl, getExtFromMimetype, VideoFetchByUrlType } from '../../helpers/video'
 import {
   ACTIVITY_PUB,
   MIMETYPES,
   P2P_MEDIA_LOADER_PEER_VERSION,
   PREVIEWS_SIZE,
   REMOTE_SCHEME,
-  STATIC_PATHS, THUMBNAILS_SIZE
+  STATIC_PATHS,
+  THUMBNAILS_SIZE
 } from '../../initializers/constants'
-import { TagModel } from '../../models/video/tag'
+import { sequelizeTypescript } from '../../initializers/database'
+import { AccountVideoRateModel } from '../../models/account/account-video-rate'
 import { VideoModel } from '../../models/video/video'
-import { VideoFileModel } from '../../models/video/video-file'
-import { getOrCreateActorAndServerAndModel } from './actor'
-import { addVideoComments } from './video-comments'
-import { crawlCollectionPage } from './crawl'
-import { sendCreateVideo, sendUpdateVideo } from './send'
-import { isArray } from '../../helpers/custom-validators/misc'
 import { VideoCaptionModel } from '../../models/video/video-caption'
-import { JobQueue } from '../job-queue'
-import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
-import { createRates } from './video-rates'
-import { addVideoShares, shareVideoByServerAndChannel } from './share'
-import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
-import { buildRemoteVideoBaseUrl, checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
-import { Notifier } from '../notifier'
-import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
-import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
-import { AccountVideoRateModel } from '../../models/account/account-video-rate'
-import { VideoShareModel } from '../../models/video/video-share'
 import { VideoCommentModel } from '../../models/video/video-comment'
-import { sequelizeTypescript } from '../../initializers/database'
-import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '../thumbnail'
-import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
-import { join } from 'path'
-import { FilteredModelAttributes } from '../../typings/sequelize'
-import { autoBlacklistVideoIfNeeded } from '../video-blacklist'
-import { ActorFollowScoreCache } from '../files-cache'
+import { VideoFileModel } from '../../models/video/video-file'
+import { VideoShareModel } from '../../models/video/video-share'
+import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
 import {
   MAccountIdActor,
   MChannelAccountLight,
@@ -68,11 +57,25 @@ import {
   MVideoAPWithoutCaption,
   MVideoFile,
   MVideoFullLight,
-  MVideoId, MVideoImmutable,
+  MVideoId,
+  MVideoImmutable,
   MVideoThumbnail
-} from '../../typings/models'
-import { MThumbnail } from '../../typings/models/video/thumbnail'
-import { maxBy, minBy } from 'lodash'
+} from '../../types/models'
+import { MThumbnail } from '../../types/models/video/thumbnail'
+import { FilteredModelAttributes } from '../../types/sequelize'
+import { ActorFollowScoreCache } from '../files-cache'
+import { JobQueue } from '../job-queue'
+import { Notifier } from '../notifier'
+import { PeerTubeSocket } from '../peertube-socket'
+import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '../thumbnail'
+import { setVideoTags } from '../video'
+import { autoBlacklistVideoIfNeeded } from '../video-blacklist'
+import { getOrCreateActorAndServerAndModel } from './actor'
+import { crawlCollectionPage } from './crawl'
+import { sendCreateVideo, sendUpdateVideo } from './send'
+import { addVideoShares, shareVideoByServerAndChannel } from './share'
+import { addVideoComments } from './video-comments'
+import { createRates } from './video-rates'
 
 async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVideo: boolean, transaction?: sequelize.Transaction) {
   const video = videoArg as MVideoAP
@@ -81,7 +84,7 @@ async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVid
     // Check this is not a blacklisted video, or unfederated blacklisted video
     (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) &&
     // Check the video is public/unlisted and published
-    video.hasPrivacyForFederation() && video.state === VideoState.PUBLISHED
+    video.hasPrivacyForFederation() && video.hasStateForFederation()
   ) {
     // Fetch more attributes that we will need to serialize in AP object
     if (isArray(video.VideoCaptions) === false) {
@@ -101,7 +104,7 @@ async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVid
   }
 }
 
-async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
+async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoObject }> {
   const options = {
     uri: videoUrl,
     method: 'GET',
@@ -111,7 +114,7 @@ async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.
 
   logger.info('Fetching remote video %s.', videoUrl)
 
-  const { response, body } = await doRequest(options)
+  const { response, body } = await doRequest<any>(options)
 
   if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
     logger.debug('Remote video JSON is not valid.', { body })
@@ -129,11 +132,11 @@ async function fetchRemoteVideoDescription (video: MVideoAccountLight) {
     json: true
   }
 
-  const { body } = await doRequest(options)
+  const { body } = await doRequest<any>(options)
   return body.description ? body.description : ''
 }
 
-function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
+function getOrCreateVideoChannelFromVideoObject (videoObject: VideoObject) {
   const channel = videoObject.attributedTo.find(a => a.type === 'Group')
   if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
 
@@ -152,7 +155,7 @@ type SyncParam = {
   thumbnail: boolean
   refreshVideo?: boolean
 }
-async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
+async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoObject, syncParam: SyncParam) {
   logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
 
   const jobPayloads: ActivitypubHttpFetcherPayload[] = []
@@ -271,23 +274,34 @@ async function getOrCreateVideoAndAccountAndChannel (
 
   const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
   const videoChannel = actor.VideoChannel
-  const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
 
-  await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam)
+  try {
+    const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
+
+    await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam)
 
-  return { video: videoCreated, created: true, autoBlacklisted }
+    return { video: videoCreated, created: true, autoBlacklisted }
+  } catch (err) {
+    // Maybe a concurrent getOrCreateVideoAndAccountAndChannel call created this video
+    if (err.name === 'SequelizeUniqueConstraintError') {
+      const fallbackVideo = await fetchVideoByUrl(videoUrl, fetchType)
+      if (fallbackVideo) return { video: fallbackVideo, created: false }
+    }
+
+    throw err
+  }
 }
 
 async function updateVideoFromAP (options: {
   video: MVideoAccountLightBlacklistAllFiles
-  videoObject: VideoTorrentObject
+  videoObject: VideoObject
   account: MAccountIdActor
   channel: MChannelDefault
   overrideTo?: string[]
 }) {
   const { video, videoObject, account, channel, overrideTo } = options
 
-  logger.debug('Updating remote video "%s".', options.videoObject.uuid, { account, channel })
+  logger.debug('Updating remote video "%s".', options.videoObject.uuid, { videoObject: options.videoObject, account, channel })
 
   let videoFieldsSave: any
   const wasPrivateVideo = video.privacy === VideoPrivacy.PRIVATE
@@ -335,6 +349,7 @@ async function updateVideoFromAP (options: {
       video.privacy = videoData.privacy
       video.channelId = videoData.channelId
       video.views = videoData.views
+      video.isLive = videoData.isLive
 
       const videoUpdated = await video.save(sequelizeOptions) as MVideoFullLight
 
@@ -396,8 +411,7 @@ async function updateVideoFromAP (options: {
         const tags = videoObject.tag
                                 .filter(isAPHashTagObject)
                                 .map(tag => tag.name)
-        const tagInstances = await TagModel.findOrCreateTags(tags, t)
-        await videoUpdated.$set('Tags', tagInstances, sequelizeOptions)
+        await setVideoTags({ video: videoUpdated, tags, transaction: t, defaultValue: videoUpdated.Tags })
       }
 
       {
@@ -410,6 +424,27 @@ async function updateVideoFromAP (options: {
         await Promise.all(videoCaptionsPromises)
       }
 
+      {
+        // Create or update existing live
+        if (video.isLive) {
+          const [ videoLive ] = await VideoLiveModel.upsert({
+            saveReplay: videoObject.liveSaveReplay,
+            videoId: video.id
+          }, { transaction: t, returning: true })
+
+          videoUpdated.VideoLive = videoLive
+        } else { // Delete existing live if it exists
+          await VideoLiveModel.destroy({
+            where: {
+              videoId: video.id
+            },
+            transaction: t
+          })
+
+          videoUpdated.VideoLive = null
+        }
+      }
+
       return videoUpdated
     })
 
@@ -422,6 +457,7 @@ async function updateVideoFromAP (options: {
     })
 
     if (wasPrivateVideo || wasUnlistedVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) // Notify our users?
+    if (videoUpdated.isLive) PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
 
     logger.info('Remote video with uuid %s updated', videoObject.uuid)
 
@@ -504,10 +540,9 @@ export {
 // ---------------------------------------------------------------------------
 
 function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {
-  const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
-
   const urlMediaType = url.mediaType
-  return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
+
+  return MIMETYPES.VIDEO.MIMETYPE_EXT[urlMediaType] && urlMediaType.startsWith('video/')
 }
 
 function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject {
@@ -526,7 +561,7 @@ function isAPHashTagObject (url: any): url is ActivityHashTagObject {
   return url && url.type === 'Hashtag'
 }
 
-async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAccountLight, waitThumbnail = false) {
+async function createVideo (videoObject: VideoObject, channel: MChannelAccountLight, waitThumbnail = false) {
   logger.debug('Adding remote video %s.', videoObject.id)
 
   const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, videoObject.to)
@@ -582,8 +617,7 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc
     const tags = videoObject.tag
                             .filter(isAPHashTagObject)
                             .map(t => t.name)
-    const tagInstances = await TagModel.findOrCreateTags(tags, t)
-    await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
+    await setVideoTags({ video: videoCreated, tags, transaction: t })
 
     // Process captions
     const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
@@ -592,7 +626,16 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc
     await Promise.all(videoCaptionsPromises)
 
     videoCreated.VideoFiles = videoFiles
-    videoCreated.Tags = tagInstances
+
+    if (videoCreated.isLive) {
+      const videoLive = new VideoLiveModel({
+        streamKey: null,
+        saveReplay: videoObject.liveSaveReplay,
+        videoId: videoCreated.id
+      })
+
+      videoCreated.VideoLive = await videoLive.save({ transaction: t })
+    }
 
     const autoBlacklisted = await autoBlacklistVideoIfNeeded({
       video: videoCreated,
@@ -622,10 +665,12 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc
   return { autoBlacklisted, videoCreated }
 }
 
-function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObject: VideoTorrentObject, to: string[] = []) {
-  const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
-  const duration = videoObject.duration.replace(/[^\d]+/, '')
+function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
+  const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
+    ? VideoPrivacy.PUBLIC
+    : VideoPrivacy.UNLISTED
 
+  const duration = videoObject.duration.replace(/[^\d]+/, '')
   const language = videoObject.language?.identifier
 
   const category = videoObject.category
@@ -652,6 +697,7 @@ function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObjec
     commentsEnabled: videoObject.commentsEnabled,
     downloadEnabled: videoObject.downloadEnabled,
     waitTranscoding: videoObject.waitTranscoding,
+    isLive: videoObject.isLiveBroadcast,
     state: videoObject.state,
     channelId: videoChannel.id,
     duration: parseInt(duration, 10),
@@ -692,13 +738,22 @@ function videoFileActivityUrlToDBAttributes (
       throw new Error('Cannot parse magnet URI ' + magnet.href)
     }
 
+    // Fetch associated metadata url, if any
+    const metadata = urls.filter(isAPVideoFileMetadataObject)
+                         .find(u => {
+                           return u.height === fileUrl.height &&
+                             u.fps === fileUrl.fps &&
+                             u.rel.includes(fileUrl.mediaType)
+                         })
+
     const mediaType = fileUrl.mediaType
     const attribute = {
-      extname: MIMETYPES.VIDEO.MIMETYPE_EXT[mediaType],
+      extname: getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, mediaType),
       infoHash: parsed.infoHash,
       resolution: fileUrl.height,
       size: fileUrl.size,
       fps: fileUrl.fps || -1,
+      metadataUrl: metadata?.href,
 
       // This is a video file owned by a video or by a streaming playlist
       videoId: (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? null : videoOrPlaylist.id,
@@ -711,7 +766,7 @@ function videoFileActivityUrlToDBAttributes (
   return attributes
 }
 
-function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObject: VideoTorrentObject, videoFiles: MVideoFile[]) {
+function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObject: VideoObject, videoFiles: MVideoFile[]) {
   const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
   if (playlistUrls.length === 0) return []
 
@@ -745,7 +800,7 @@ function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObjec
   return attributes
 }
 
-function getThumbnailFromIcons (videoObject: VideoTorrentObject) {
+function getThumbnailFromIcons (videoObject: VideoObject) {
   let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
   // Fallback if there are not valid icons
   if (validIcons.length === 0) validIcons = videoObject.icon
@@ -753,7 +808,7 @@ function getThumbnailFromIcons (videoObject: VideoTorrentObject) {
   return minBy(validIcons, 'width')
 }
 
-function getPreviewFromIcons (videoObject: VideoTorrentObject) {
+function getPreviewFromIcons (videoObject: VideoObject) {
   const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth)
 
   // FIXME: don't put a fallback here for compatibility with PeerTube <2.2