From 08a47c75f992e7138dca5121f227909a8347d365 Mon Sep 17 00:00:00 2001
From: Chocobozzz <me@florianbigard.com>
Date: Wed, 2 Jun 2021 10:41:46 +0200
Subject: [PATCH] Refactor AP video create/update

---
 server/lib/activitypub/videos/fetch.ts        |  44 +--
 server/lib/activitypub/videos/index.ts        |   2 +-
 .../videos/shared/abstract-builder.ts         | 142 +++++++++
 .../lib/activitypub/videos/shared/creator.ts  |  90 ++++++
 server/lib/activitypub/videos/shared/index.ts |   3 +-
 .../shared/object-to-model-attributes.ts      |  34 +-
 .../activitypub/videos/shared/video-create.ts | 167 ----------
 server/lib/activitypub/videos/update.ts       | 293 ------------------
 server/lib/activitypub/videos/updater.ts      | 170 ++++++++++
 shared/extra-utils/videos/videos.ts           |   2 +
 10 files changed, 457 insertions(+), 490 deletions(-)
 create mode 100644 server/lib/activitypub/videos/shared/abstract-builder.ts
 create mode 100644 server/lib/activitypub/videos/shared/creator.ts
 delete mode 100644 server/lib/activitypub/videos/shared/video-create.ts
 delete mode 100644 server/lib/activitypub/videos/update.ts
 create mode 100644 server/lib/activitypub/videos/updater.ts

diff --git a/server/lib/activitypub/videos/fetch.ts b/server/lib/activitypub/videos/fetch.ts
index fdcf4ee5c..5e7f8552b 100644
--- a/server/lib/activitypub/videos/fetch.ts
+++ b/server/lib/activitypub/videos/fetch.ts
@@ -1,20 +1,19 @@
-import { checkUrlsSameHost, getAPId } from "@server/helpers/activitypub"
-import { sanitizeAndCheckVideoTorrentObject } from "@server/helpers/custom-validators/activitypub/videos"
-import { retryTransactionWrapper } from "@server/helpers/database-utils"
-import { logger } from "@server/helpers/logger"
-import { doJSONRequest, PeerTubeRequestError } from "@server/helpers/requests"
-import { fetchVideoByUrl, VideoFetchByUrlType } from "@server/helpers/video"
-import { REMOTE_SCHEME } from "@server/initializers/constants"
-import { ActorFollowScoreCache } from "@server/lib/files-cache"
-import { JobQueue } from "@server/lib/job-queue"
-import { VideoModel } from "@server/models/video/video"
-import { MVideoAccountLight, MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from "@server/types/models"
-import { HttpStatusCode } from "@shared/core-utils"
-import { VideoObject } from "@shared/models"
-import { getOrCreateActorAndServerAndModel } from "../actor"
-import { SyncParam, syncVideoExternalAttributes } from "./shared"
-import { createVideo } from "./shared/video-create"
-import { APVideoUpdater } from "./update"
+import { checkUrlsSameHost, getAPId } from '@server/helpers/activitypub'
+import { sanitizeAndCheckVideoTorrentObject } from '@server/helpers/custom-validators/activitypub/videos'
+import { retryTransactionWrapper } from '@server/helpers/database-utils'
+import { logger } from '@server/helpers/logger'
+import { doJSONRequest, PeerTubeRequestError } from '@server/helpers/requests'
+import { fetchVideoByUrl, VideoFetchByUrlType } from '@server/helpers/video'
+import { REMOTE_SCHEME } from '@server/initializers/constants'
+import { ActorFollowScoreCache } from '@server/lib/files-cache'
+import { JobQueue } from '@server/lib/job-queue'
+import { VideoModel } from '@server/models/video/video'
+import { MVideoAccountLight, MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models'
+import { HttpStatusCode } from '@shared/core-utils'
+import { VideoObject } from '@shared/models'
+import { getOrCreateActorAndServerAndModel } from '../actor'
+import { APVideoCreator, SyncParam, syncVideoExternalAttributes } from './shared'
+import { APVideoUpdater } from './updater'
 
 async function fetchRemoteVideo (videoUrl: string): Promise<{ statusCode: number, videoObject: VideoObject }> {
   logger.info('Fetching remote video %s.', videoUrl)
@@ -115,16 +114,17 @@ async function getOrCreateVideoAndAccountAndChannel (
     return { video: videoFromDatabase, created: false }
   }
 
-  const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
-  if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
+  const { videoObject } = await fetchRemoteVideo(videoUrl)
+  if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
 
-  const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
+  const actor = await getOrCreateVideoChannelFromVideoObject(videoObject)
   const videoChannel = actor.VideoChannel
 
   try {
-    const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
+    const creator = new APVideoCreator({ videoObject, channel: videoChannel })
+    const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(creator.create.bind(creator), syncParam.thumbnail)
 
-    await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam)
+    await syncVideoExternalAttributes(videoCreated, videoObject, syncParam)
 
     return { video: videoCreated, created: true, autoBlacklisted }
   } catch (err) {
diff --git a/server/lib/activitypub/videos/index.ts b/server/lib/activitypub/videos/index.ts
index 0e126c85a..b560acb76 100644
--- a/server/lib/activitypub/videos/index.ts
+++ b/server/lib/activitypub/videos/index.ts
@@ -1,3 +1,3 @@
 export * from './federate'
 export * from './fetch'
-export * from './update'
+export * from './updater'
diff --git a/server/lib/activitypub/videos/shared/abstract-builder.ts b/server/lib/activitypub/videos/shared/abstract-builder.ts
new file mode 100644
index 000000000..9d5f37e5f
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/abstract-builder.ts
@@ -0,0 +1,142 @@
+import { Transaction } from 'sequelize/types'
+import { deleteNonExistingModels } from '@server/helpers/database-utils'
+import { logger } from '@server/helpers/logger'
+import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '@server/lib/thumbnail'
+import { setVideoTags } from '@server/lib/video'
+import { VideoCaptionModel } from '@server/models/video/video-caption'
+import { VideoFileModel } from '@server/models/video/video-file'
+import { VideoLiveModel } from '@server/models/video/video-live'
+import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
+import { MStreamingPlaylistFilesVideo, MThumbnail, MVideoCaption, MVideoFile, MVideoFullLight, MVideoThumbnail } from '@server/types/models'
+import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType } from '@shared/models'
+import {
+  getCaptionAttributesFromObject,
+  getFileAttributesFromUrl,
+  getLiveAttributesFromObject,
+  getPreviewFromIcons,
+  getStreamingPlaylistAttributesFromObject,
+  getTagsFromObject,
+  getThumbnailFromIcons
+} from './object-to-model-attributes'
+import { getTrackerUrls, setVideoTrackers } from './trackers'
+
+export abstract class APVideoAbstractBuilder {
+  protected abstract videoObject: VideoObject
+
+  protected tryToGenerateThumbnail (video: MVideoThumbnail): Promise<MThumbnail> {
+    return createVideoMiniatureFromUrl({
+      downloadUrl: getThumbnailFromIcons(this.videoObject).url,
+      video,
+      type: ThumbnailType.MINIATURE
+    }).catch(err => {
+      logger.warn('Cannot generate thumbnail of %s.', this.videoObject.id, { err })
+
+      return undefined
+    })
+  }
+
+  protected async setPreview (video: MVideoFullLight, t: Transaction) {
+    // Don't fetch the preview that could be big, create a placeholder instead
+    const previewIcon = getPreviewFromIcons(this.videoObject)
+    if (!previewIcon) return
+
+    const previewModel = createPlaceholderThumbnail({
+      fileUrl: previewIcon.url,
+      video,
+      type: ThumbnailType.PREVIEW,
+      size: previewIcon
+    })
+
+    await video.addAndSaveThumbnail(previewModel, t)
+  }
+
+  protected async setTags (video: MVideoFullLight, t: Transaction) {
+    const tags = getTagsFromObject(this.videoObject)
+    await setVideoTags({ video, tags, transaction: t })
+  }
+
+  protected async setTrackers (video: MVideoFullLight, t: Transaction) {
+    const trackers = getTrackerUrls(this.videoObject, video)
+    await setVideoTrackers({ video, trackers, transaction: t })
+  }
+
+  protected async insertOrReplaceCaptions (video: MVideoFullLight, t: Transaction) {
+    const videoCaptionsPromises = getCaptionAttributesFromObject(video, this.videoObject)
+      .map(a => new VideoCaptionModel(a) as MVideoCaption)
+      .map(c => VideoCaptionModel.insertOrReplaceLanguage(c, t))
+
+    await Promise.all(videoCaptionsPromises)
+  }
+
+  protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) {
+    const attributes = getLiveAttributesFromObject(video, this.videoObject)
+    const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
+
+    video.VideoLive = videoLive
+  }
+
+  protected async setWebTorrentFiles (video: MVideoFullLight, t: Transaction) {
+    const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url)
+    const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
+
+    // Remove video files that do not exist anymore
+    const destroyTasks = deleteNonExistingModels(video.VideoFiles || [], newVideoFiles, t)
+    await Promise.all(destroyTasks)
+
+    // Update or add other one
+    const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
+    video.VideoFiles = await Promise.all(upsertTasks)
+  }
+
+  protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
+    const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject, video.VideoFiles || [])
+    const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
+
+    // Remove video playlists that do not exist anymore
+    const destroyTasks = deleteNonExistingModels(video.VideoStreamingPlaylists || [], newStreamingPlaylists, t)
+    await Promise.all(destroyTasks)
+
+    video.VideoStreamingPlaylists = []
+
+    for (const playlistAttributes of streamingPlaylistAttributes) {
+
+      const streamingPlaylistModel = await this.insertOrReplaceStreamingPlaylist(playlistAttributes, t)
+      streamingPlaylistModel.Video = video
+
+      await this.setStreamingPlaylistFiles(video, streamingPlaylistModel, playlistAttributes.tagAPObject, t)
+
+      video.VideoStreamingPlaylists.push(streamingPlaylistModel)
+    }
+  }
+
+  private async insertOrReplaceStreamingPlaylist (attributes: VideoStreamingPlaylistModel['_creationAttributes'], t: Transaction) {
+    const [ streamingPlaylist ] = await VideoStreamingPlaylistModel.upsert(attributes, { returning: true, transaction: t })
+
+    return streamingPlaylist as MStreamingPlaylistFilesVideo
+  }
+
+  private getStreamingPlaylistFiles (video: MVideoFullLight, type: VideoStreamingPlaylistType) {
+    const playlist = video.VideoStreamingPlaylists.find(s => s.type === type)
+    if (!playlist) return []
+
+    return playlist.VideoFiles
+  }
+
+  private async setStreamingPlaylistFiles (
+    video: MVideoFullLight,
+    playlistModel: MStreamingPlaylistFilesVideo,
+    tagObjects: ActivityTagObject[],
+    t: Transaction
+  ) {
+    const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(video, playlistModel.type)
+
+    const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a))
+
+    const destroyTasks = deleteNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles, t)
+    await Promise.all(destroyTasks)
+
+    // Update or add other one
+    const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
+    playlistModel.VideoFiles = await Promise.all(upsertTasks)
+  }
+}
diff --git a/server/lib/activitypub/videos/shared/creator.ts b/server/lib/activitypub/videos/shared/creator.ts
new file mode 100644
index 000000000..4f2d79374
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/creator.ts
@@ -0,0 +1,90 @@
+
+import { logger } from '@server/helpers/logger'
+import { sequelizeTypescript } from '@server/initializers/database'
+import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
+import { VideoModel } from '@server/models/video/video'
+import { MChannelAccountLight, MThumbnail, MVideoFullLight, MVideoThumbnail } from '@server/types/models'
+import { VideoObject } from '@shared/models'
+import { APVideoAbstractBuilder } from './abstract-builder'
+import { getVideoAttributesFromObject } from './object-to-model-attributes'
+
+export class APVideoCreator extends APVideoAbstractBuilder {
+  protected readonly videoObject: VideoObject
+  private readonly channel: MChannelAccountLight
+
+  constructor (options: {
+    videoObject: VideoObject
+    channel: MChannelAccountLight
+  }) {
+    super()
+
+    this.videoObject = options.videoObject
+    this.channel = options.channel
+  }
+
+  async create (waitThumbnail = false) {
+    logger.debug('Adding remote video %s.', this.videoObject.id)
+
+    const videoData = await getVideoAttributesFromObject(this.channel, this.videoObject, this.videoObject.to)
+    const video = VideoModel.build(videoData) as MVideoThumbnail
+
+    const promiseThumbnail = this.tryToGenerateThumbnail(video)
+
+    let thumbnailModel: MThumbnail
+    if (waitThumbnail === true) {
+      thumbnailModel = await promiseThumbnail
+    }
+
+    const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
+      try {
+        const videoCreated = await video.save({ transaction: t }) as MVideoFullLight
+        videoCreated.VideoChannel = this.channel
+
+        if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
+
+        await this.setPreview(videoCreated, t)
+        await this.setWebTorrentFiles(videoCreated, t)
+        await this.setStreamingPlaylists(videoCreated, t)
+        await this.setTags(videoCreated, t)
+        await this.setTrackers(videoCreated, t)
+        await this.insertOrReplaceCaptions(videoCreated, t)
+        await this.insertOrReplaceLive(videoCreated, t)
+
+        // We added a video in this channel, set it as updated
+        await this.channel.setAsUpdated(t)
+
+        const autoBlacklisted = await autoBlacklistVideoIfNeeded({
+          video: videoCreated,
+          user: undefined,
+          isRemote: true,
+          isNew: true,
+          transaction: t
+        })
+
+        logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid)
+
+        return { autoBlacklisted, videoCreated }
+      } catch (err) {
+        // FIXME: Use rollback hook when https://github.com/sequelize/sequelize/pull/13038 is released
+        // Remove thumbnail
+        if (thumbnailModel) await thumbnailModel.removeThumbnail()
+
+        throw err
+      }
+    })
+
+    if (waitThumbnail === false) {
+      // Error is already caught above
+      // eslint-disable-next-line @typescript-eslint/no-floating-promises
+      promiseThumbnail.then(thumbnailModel => {
+        if (!thumbnailModel) return
+
+        thumbnailModel = videoCreated.id
+
+        return thumbnailModel.save()
+      })
+    }
+
+    return { autoBlacklisted, videoCreated }
+  }
+}
diff --git a/server/lib/activitypub/videos/shared/index.ts b/server/lib/activitypub/videos/shared/index.ts
index 4d24fbc6a..208a43705 100644
--- a/server/lib/activitypub/videos/shared/index.ts
+++ b/server/lib/activitypub/videos/shared/index.ts
@@ -1,4 +1,5 @@
+export * from './abstract-builder'
+export * from './creator'
 export * from './object-to-model-attributes'
 export * from './trackers'
-export * from './video-create'
 export * from './video-sync-attributes'
diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
index 8a8105500..85548428c 100644
--- a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
+++ b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
@@ -23,6 +23,7 @@ import {
   VideoPrivacy,
   VideoStreamingPlaylistType
 } from '@shared/models'
+import { VideoCaptionModel } from '@server/models/video/video-caption'
 
 function getThumbnailFromIcons (videoObject: VideoObject) {
   let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
@@ -44,7 +45,7 @@ function getTagsFromObject (videoObject: VideoObject) {
     .map(t => t.name)
 }
 
-function videoFileActivityUrlToDBAttributes (
+function getFileAttributesFromUrl (
   videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
   urls: (ActivityTagObject | ActivityUrlObject)[]
 ) {
@@ -109,7 +110,7 @@ function videoFileActivityUrlToDBAttributes (
   return attributes
 }
 
-function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObject: VideoObject, videoFiles: MVideoFile[]) {
+function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject, videoFiles: MVideoFile[]) {
   const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
   if (playlistUrls.length === 0) return []
 
@@ -134,6 +135,7 @@ function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObjec
       p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files),
       p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
       videoId: video.id,
+
       tagAPObject: playlistUrlObject.tag
     }
 
@@ -143,7 +145,24 @@ function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObjec
   return attributes
 }
 
-function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
+function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
+  return {
+    saveReplay: videoObject.liveSaveReplay,
+    permanentLive: videoObject.permanentLive,
+    videoId: video.id
+  }
+}
+
+function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
+  return videoObject.subtitleLanguage.map(c => ({
+    videoId: video.id,
+    filename: VideoCaptionModel.generateCaptionName(c.identifier),
+    language: c.identifier,
+    fileUrl: c.url
+  }))
+}
+
+function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
   const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
     ? VideoPrivacy.PUBLIC
     : VideoPrivacy.UNLISTED
@@ -203,10 +222,13 @@ export {
 
   getTagsFromObject,
 
-  videoActivityObjectToDBAttributes,
+  getFileAttributesFromUrl,
+  getStreamingPlaylistAttributesFromObject,
 
-  videoFileActivityUrlToDBAttributes,
-  streamingPlaylistActivityUrlToDBAttributes
+  getLiveAttributesFromObject,
+  getCaptionAttributesFromObject,
+
+  getVideoAttributesFromObject
 }
 
 // ---------------------------------------------------------------------------
diff --git a/server/lib/activitypub/videos/shared/video-create.ts b/server/lib/activitypub/videos/shared/video-create.ts
deleted file mode 100644
index 80cc2ab37..000000000
--- a/server/lib/activitypub/videos/shared/video-create.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import { logger } from '@server/helpers/logger'
-import { sequelizeTypescript } from '@server/initializers/database'
-import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '@server/lib/thumbnail'
-import { setVideoTags } from '@server/lib/video'
-import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
-import { VideoModel } from '@server/models/video/video'
-import { VideoCaptionModel } from '@server/models/video/video-caption'
-import { VideoFileModel } from '@server/models/video/video-file'
-import { VideoLiveModel } from '@server/models/video/video-live'
-import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
-import {
-  MChannelAccountLight,
-  MStreamingPlaylistFilesVideo,
-  MThumbnail,
-  MVideoCaption,
-  MVideoFullLight,
-  MVideoThumbnail
-} from '@server/types/models'
-import { ThumbnailType, VideoObject } from '@shared/models'
-import {
-  getPreviewFromIcons,
-  getTagsFromObject,
-  getThumbnailFromIcons,
-  streamingPlaylistActivityUrlToDBAttributes,
-  videoActivityObjectToDBAttributes,
-  videoFileActivityUrlToDBAttributes
-} from './object-to-model-attributes'
-import { getTrackerUrls, setVideoTrackers } from './trackers'
-
-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)
-  const video = VideoModel.build(videoData) as MVideoThumbnail
-
-  const promiseThumbnail = createVideoMiniatureFromUrl({
-    downloadUrl: getThumbnailFromIcons(videoObject).url,
-    video,
-    type: ThumbnailType.MINIATURE
-  }).catch(err => {
-    logger.error('Cannot create miniature from url.', { err })
-    return undefined
-  })
-
-  let thumbnailModel: MThumbnail
-  if (waitThumbnail === true) {
-    thumbnailModel = await promiseThumbnail
-  }
-
-  const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
-    try {
-      const sequelizeOptions = { transaction: t }
-
-      const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
-      videoCreated.VideoChannel = channel
-
-      if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
-
-      const previewIcon = getPreviewFromIcons(videoObject)
-      if (previewIcon) {
-        const previewModel = createPlaceholderThumbnail({
-          fileUrl: previewIcon.url,
-          video: videoCreated,
-          type: ThumbnailType.PREVIEW,
-          size: previewIcon
-        })
-
-        await videoCreated.addAndSaveThumbnail(previewModel, t)
-      }
-
-      // Process files
-      const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject.url)
-
-      const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
-      const videoFiles = await Promise.all(videoFilePromises)
-
-      const streamingPlaylistsAttributes = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles)
-      videoCreated.VideoStreamingPlaylists = []
-
-      for (const playlistAttributes of streamingPlaylistsAttributes) {
-        const playlist = await VideoStreamingPlaylistModel.create(playlistAttributes, { transaction: t }) as MStreamingPlaylistFilesVideo
-        playlist.Video = videoCreated
-
-        const playlistFiles = videoFileActivityUrlToDBAttributes(playlist, playlistAttributes.tagAPObject)
-        const videoFilePromises = playlistFiles.map(f => VideoFileModel.create(f, { transaction: t }))
-        playlist.VideoFiles = await Promise.all(videoFilePromises)
-
-        videoCreated.VideoStreamingPlaylists.push(playlist)
-      }
-
-      // Process tags
-      const tags = getTagsFromObject(videoObject)
-      await setVideoTags({ video: videoCreated, tags, transaction: t })
-
-      // Process captions
-      const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
-        const caption = new VideoCaptionModel({
-          videoId: videoCreated.id,
-          filename: VideoCaptionModel.generateCaptionName(c.identifier),
-          language: c.identifier,
-          fileUrl: c.url
-        }) as MVideoCaption
-
-        return VideoCaptionModel.insertOrReplaceLanguage(caption, t)
-      })
-      await Promise.all(videoCaptionsPromises)
-
-      // Process trackers
-      {
-        const trackers = getTrackerUrls(videoObject, videoCreated)
-        await setVideoTrackers({ video: videoCreated, trackers, transaction: t })
-      }
-
-      videoCreated.VideoFiles = videoFiles
-
-      if (videoCreated.isLive) {
-        const videoLive = new VideoLiveModel({
-          streamKey: null,
-          saveReplay: videoObject.liveSaveReplay,
-          permanentLive: videoObject.permanentLive,
-          videoId: videoCreated.id
-        })
-
-        videoCreated.VideoLive = await videoLive.save({ transaction: t })
-      }
-
-      // We added a video in this channel, set it as updated
-      await channel.setAsUpdated(t)
-
-      const autoBlacklisted = await autoBlacklistVideoIfNeeded({
-        video: videoCreated,
-        user: undefined,
-        isRemote: true,
-        isNew: true,
-        transaction: t
-      })
-
-      logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
-
-      return { autoBlacklisted, videoCreated }
-    } catch (err) {
-      // FIXME: Use rollback hook when https://github.com/sequelize/sequelize/pull/13038 is released
-      // Remove thumbnail
-      if (thumbnailModel) await thumbnailModel.removeThumbnail()
-
-      throw err
-    }
-  })
-
-  if (waitThumbnail === false) {
-    // Error is already caught above
-    // eslint-disable-next-line @typescript-eslint/no-floating-promises
-    promiseThumbnail.then(thumbnailModel => {
-      if (!thumbnailModel) return
-
-      thumbnailModel = videoCreated.id
-
-      return thumbnailModel.save()
-    })
-  }
-
-  return { autoBlacklisted, videoCreated }
-}
-
-export {
-  createVideo
-}
diff --git a/server/lib/activitypub/videos/update.ts b/server/lib/activitypub/videos/update.ts
deleted file mode 100644
index 444b51628..000000000
--- a/server/lib/activitypub/videos/update.ts
+++ /dev/null
@@ -1,293 +0,0 @@
-import { Transaction } from 'sequelize/types'
-import { deleteNonExistingModels, resetSequelizeInstance } from '@server/helpers/database-utils'
-import { logger } from '@server/helpers/logger'
-import { sequelizeTypescript } from '@server/initializers/database'
-import { Notifier } from '@server/lib/notifier'
-import { PeerTubeSocket } from '@server/lib/peertube-socket'
-import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '@server/lib/thumbnail'
-import { setVideoTags } from '@server/lib/video'
-import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
-import { VideoCaptionModel } from '@server/models/video/video-caption'
-import { VideoFileModel } from '@server/models/video/video-file'
-import { VideoLiveModel } from '@server/models/video/video-live'
-import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
-import {
-  MChannelAccountLight,
-  MChannelDefault,
-  MStreamingPlaylistFilesVideo,
-  MThumbnail,
-  MVideoAccountLightBlacklistAllFiles,
-  MVideoCaption,
-  MVideoFile,
-  MVideoFullLight
-} from '@server/types/models'
-import { ThumbnailType, VideoObject, VideoPrivacy } from '@shared/models'
-import {
-  getPreviewFromIcons,
-  getTagsFromObject,
-  getThumbnailFromIcons,
-  getTrackerUrls,
-  setVideoTrackers,
-  streamingPlaylistActivityUrlToDBAttributes,
-  videoActivityObjectToDBAttributes,
-  videoFileActivityUrlToDBAttributes
-} from './shared'
-
-export class APVideoUpdater {
-  private readonly video: MVideoAccountLightBlacklistAllFiles
-  private readonly videoObject: VideoObject
-  private readonly channel: MChannelDefault
-  private readonly overrideTo: string[]
-
-  private readonly wasPrivateVideo: boolean
-  private readonly wasUnlistedVideo: boolean
-
-  private readonly videoFieldsSave: any
-
-  private readonly oldVideoChannel: MChannelAccountLight
-
-  constructor (options: {
-    video: MVideoAccountLightBlacklistAllFiles
-    videoObject: VideoObject
-    channel: MChannelDefault
-    overrideTo?: string[]
-  }) {
-    this.video = options.video
-    this.videoObject = options.videoObject
-    this.channel = options.channel
-    this.overrideTo = options.overrideTo
-
-    this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE
-    this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED
-
-    this.oldVideoChannel = this.video.VideoChannel
-
-    this.videoFieldsSave = this.video.toJSON()
-  }
-
-  async update () {
-    logger.debug('Updating remote video "%s".', this.videoObject.uuid, { videoObject: this.videoObject, channel: this.channel })
-
-    try {
-      const thumbnailModel = await this.tryToGenerateThumbnail()
-
-      const videoUpdated = await sequelizeTypescript.transaction(async t => {
-        this.checkChannelUpdateOrThrow()
-
-        const videoUpdated = await this.updateVideo(t)
-
-        await this.processIcons(videoUpdated, thumbnailModel, t)
-        await this.processWebTorrentFiles(videoUpdated, t)
-        await this.processStreamingPlaylists(videoUpdated, t)
-        await this.processTags(videoUpdated, t)
-        await this.processTrackers(videoUpdated, t)
-        await this.processCaptions(videoUpdated, t)
-        await this.processLive(videoUpdated, t)
-
-        return videoUpdated
-      })
-
-      await autoBlacklistVideoIfNeeded({
-        video: videoUpdated,
-        user: undefined,
-        isRemote: true,
-        isNew: false,
-        transaction: undefined
-      })
-
-      // Notify our users?
-      if (this.wasPrivateVideo || this.wasUnlistedVideo) {
-        Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated)
-      }
-
-      if (videoUpdated.isLive) {
-        PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
-        PeerTubeSocket.Instance.sendVideoViewsUpdate(videoUpdated)
-      }
-
-      logger.info('Remote video with uuid %s updated', this.videoObject.uuid)
-
-      return videoUpdated
-    } catch (err) {
-      this.catchUpdateError(err)
-    }
-  }
-
-  private tryToGenerateThumbnail (): Promise<MThumbnail> {
-    return createVideoMiniatureFromUrl({
-      downloadUrl: getThumbnailFromIcons(this.videoObject).url,
-      video: this.video,
-      type: ThumbnailType.MINIATURE
-    }).catch(err => {
-      logger.warn('Cannot generate thumbnail of %s.', this.videoObject.id, { err })
-
-      return undefined
-    })
-  }
-
-  // Check we can update the channel: we trust the remote server
-  private checkChannelUpdateOrThrow () {
-    if (!this.oldVideoChannel.Actor.serverId || !this.channel.Actor.serverId) {
-      throw new Error('Cannot check old channel/new channel validity because `serverId` is null')
-    }
-
-    if (this.oldVideoChannel.Actor.serverId !== this.channel.Actor.serverId) {
-      throw new Error(`New channel ${this.channel.Actor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.url}`)
-    }
-  }
-
-  private updateVideo (transaction: Transaction) {
-    const to = this.overrideTo || this.videoObject.to
-    const videoData = videoActivityObjectToDBAttributes(this.channel, this.videoObject, to)
-    this.video.name = videoData.name
-    this.video.uuid = videoData.uuid
-    this.video.url = videoData.url
-    this.video.category = videoData.category
-    this.video.licence = videoData.licence
-    this.video.language = videoData.language
-    this.video.description = videoData.description
-    this.video.support = videoData.support
-    this.video.nsfw = videoData.nsfw
-    this.video.commentsEnabled = videoData.commentsEnabled
-    this.video.downloadEnabled = videoData.downloadEnabled
-    this.video.waitTranscoding = videoData.waitTranscoding
-    this.video.state = videoData.state
-    this.video.duration = videoData.duration
-    this.video.createdAt = videoData.createdAt
-    this.video.publishedAt = videoData.publishedAt
-    this.video.originallyPublishedAt = videoData.originallyPublishedAt
-    this.video.privacy = videoData.privacy
-    this.video.channelId = videoData.channelId
-    this.video.views = videoData.views
-    this.video.isLive = videoData.isLive
-
-    // Ensures we update the updated video attribute
-    this.video.changed('updatedAt', true)
-
-    return this.video.save({ transaction }) as Promise<MVideoFullLight>
-  }
-
-  private async processIcons (videoUpdated: MVideoFullLight, thumbnailModel: MThumbnail, t: Transaction) {
-    if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel, t)
-
-    // Don't fetch the preview that could be big, create a placeholder instead
-    const previewIcon = getPreviewFromIcons(this.videoObject)
-    if (videoUpdated.getPreview() && previewIcon) {
-      const previewModel = createPlaceholderThumbnail({
-        fileUrl: previewIcon.url,
-        video: videoUpdated,
-        type: ThumbnailType.PREVIEW,
-        size: previewIcon
-      })
-      await videoUpdated.addAndSaveThumbnail(previewModel, t)
-    }
-  }
-
-  private async processWebTorrentFiles (videoUpdated: MVideoFullLight, t: Transaction) {
-    const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoUpdated, this.videoObject.url)
-    const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
-
-    // Remove video files that do not exist anymore
-    const destroyTasks = deleteNonExistingModels(videoUpdated.VideoFiles, newVideoFiles, t)
-    await Promise.all(destroyTasks)
-
-    // Update or add other one
-    const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
-    videoUpdated.VideoFiles = await Promise.all(upsertTasks)
-  }
-
-  private async processStreamingPlaylists (videoUpdated: MVideoFullLight, t: Transaction) {
-    const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(videoUpdated, this.videoObject, videoUpdated.VideoFiles)
-    const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
-
-    // Remove video playlists that do not exist anymore
-    const destroyTasks = deleteNonExistingModels(videoUpdated.VideoStreamingPlaylists, newStreamingPlaylists, t)
-    await Promise.all(destroyTasks)
-
-    let oldStreamingPlaylistFiles: MVideoFile[] = []
-    for (const videoStreamingPlaylist of videoUpdated.VideoStreamingPlaylists) {
-      oldStreamingPlaylistFiles = oldStreamingPlaylistFiles.concat(videoStreamingPlaylist.VideoFiles)
-    }
-
-    videoUpdated.VideoStreamingPlaylists = []
-
-    for (const playlistAttributes of streamingPlaylistAttributes) {
-      const streamingPlaylistModel = await VideoStreamingPlaylistModel.upsert(playlistAttributes, { returning: true, transaction: t })
-                                 .then(([ streamingPlaylist ]) => streamingPlaylist as MStreamingPlaylistFilesVideo)
-      streamingPlaylistModel.Video = videoUpdated
-
-      const newVideoFiles: MVideoFile[] = videoFileActivityUrlToDBAttributes(streamingPlaylistModel, playlistAttributes.tagAPObject)
-        .map(a => new VideoFileModel(a))
-      const destroyTasks = deleteNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles, t)
-      await Promise.all(destroyTasks)
-
-      // Update or add other one
-      const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
-      streamingPlaylistModel.VideoFiles = await Promise.all(upsertTasks)
-
-      videoUpdated.VideoStreamingPlaylists.push(streamingPlaylistModel)
-    }
-  }
-
-  private async processTags (videoUpdated: MVideoFullLight, t: Transaction) {
-    const tags = getTagsFromObject(this.videoObject)
-    await setVideoTags({ video: videoUpdated, tags, transaction: t })
-  }
-
-  private async processTrackers (videoUpdated: MVideoFullLight, t: Transaction) {
-    const trackers = getTrackerUrls(this.videoObject, videoUpdated)
-    await setVideoTrackers({ video: videoUpdated, trackers, transaction: t })
-  }
-
-  private async processCaptions (videoUpdated: MVideoFullLight, t: Transaction) {
-    // Update captions
-    await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoUpdated.id, t)
-
-    const videoCaptionsPromises = this.videoObject.subtitleLanguage.map(c => {
-      const caption = new VideoCaptionModel({
-        videoId: videoUpdated.id,
-        filename: VideoCaptionModel.generateCaptionName(c.identifier),
-        language: c.identifier,
-        fileUrl: c.url
-      }) as MVideoCaption
-
-      return VideoCaptionModel.insertOrReplaceLanguage(caption, t)
-    })
-
-    await Promise.all(videoCaptionsPromises)
-  }
-
-  private async processLive (videoUpdated: MVideoFullLight, t: Transaction) {
-    // Create or update existing live
-    if (this.video.isLive) {
-      const [ videoLive ] = await VideoLiveModel.upsert({
-        saveReplay: this.videoObject.liveSaveReplay,
-        permanentLive: this.videoObject.permanentLive,
-        videoId: this.video.id
-      }, { transaction: t, returning: true })
-
-      videoUpdated.VideoLive = videoLive
-      return
-    }
-
-    // Delete existing live if it exists
-    await VideoLiveModel.destroy({
-      where: {
-        videoId: this.video.id
-      },
-      transaction: t
-    })
-
-    videoUpdated.VideoLive = null
-  }
-
-  private catchUpdateError (err: Error) {
-    if (this.video !== undefined && this.videoFieldsSave !== undefined) {
-      resetSequelizeInstance(this.video, this.videoFieldsSave)
-    }
-
-    // This is just a debug because we will retry the insert
-    logger.debug('Cannot update the remote video.', { err })
-    throw err
-  }
-}
diff --git a/server/lib/activitypub/videos/updater.ts b/server/lib/activitypub/videos/updater.ts
new file mode 100644
index 000000000..4338d1e22
--- /dev/null
+++ b/server/lib/activitypub/videos/updater.ts
@@ -0,0 +1,170 @@
+import { Transaction } from 'sequelize/types'
+import { resetSequelizeInstance } from '@server/helpers/database-utils'
+import { logger } from '@server/helpers/logger'
+import { sequelizeTypescript } from '@server/initializers/database'
+import { Notifier } from '@server/lib/notifier'
+import { PeerTubeSocket } from '@server/lib/peertube-socket'
+import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
+import { VideoCaptionModel } from '@server/models/video/video-caption'
+import { VideoLiveModel } from '@server/models/video/video-live'
+import { MChannelAccountLight, MChannelDefault, MVideoAccountLightBlacklistAllFiles, MVideoFullLight } from '@server/types/models'
+import { VideoObject, VideoPrivacy } from '@shared/models'
+import { APVideoAbstractBuilder, getVideoAttributesFromObject } from './shared'
+
+export class APVideoUpdater extends APVideoAbstractBuilder {
+  protected readonly videoObject: VideoObject
+
+  private readonly video: MVideoAccountLightBlacklistAllFiles
+  private readonly channel: MChannelDefault
+  private readonly overrideTo: string[]
+
+  private readonly wasPrivateVideo: boolean
+  private readonly wasUnlistedVideo: boolean
+
+  private readonly videoFieldsSave: any
+
+  private readonly oldVideoChannel: MChannelAccountLight
+
+  constructor (options: {
+    video: MVideoAccountLightBlacklistAllFiles
+    videoObject: VideoObject
+    channel: MChannelDefault
+    overrideTo?: string[]
+  }) {
+    super()
+
+    this.video = options.video
+    this.videoObject = options.videoObject
+    this.channel = options.channel
+    this.overrideTo = options.overrideTo
+
+    this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE
+    this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED
+
+    this.oldVideoChannel = this.video.VideoChannel
+
+    this.videoFieldsSave = this.video.toJSON()
+  }
+
+  async update () {
+    logger.debug('Updating remote video "%s".', this.videoObject.uuid, { videoObject: this.videoObject, channel: this.channel })
+
+    try {
+      const thumbnailModel = await this.tryToGenerateThumbnail(this.video)
+
+      const videoUpdated = await sequelizeTypescript.transaction(async t => {
+        this.checkChannelUpdateOrThrow()
+
+        const videoUpdated = await this.updateVideo(t)
+
+        if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel, t)
+
+        await this.setPreview(videoUpdated, t)
+        await this.setWebTorrentFiles(videoUpdated, t)
+        await this.setStreamingPlaylists(videoUpdated, t)
+        await this.setTags(videoUpdated, t)
+        await this.setTrackers(videoUpdated, t)
+        await this.setCaptions(videoUpdated, t)
+        await this.setOrDeleteLive(videoUpdated, t)
+
+        return videoUpdated
+      })
+
+      await autoBlacklistVideoIfNeeded({
+        video: videoUpdated,
+        user: undefined,
+        isRemote: true,
+        isNew: false,
+        transaction: undefined
+      })
+
+      // Notify our users?
+      if (this.wasPrivateVideo || this.wasUnlistedVideo) {
+        Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated)
+      }
+
+      if (videoUpdated.isLive) {
+        PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
+        PeerTubeSocket.Instance.sendVideoViewsUpdate(videoUpdated)
+      }
+
+      logger.info('Remote video with uuid %s updated', this.videoObject.uuid)
+
+      return videoUpdated
+    } catch (err) {
+      this.catchUpdateError(err)
+    }
+  }
+
+  // Check we can update the channel: we trust the remote server
+  private checkChannelUpdateOrThrow () {
+    if (!this.oldVideoChannel.Actor.serverId || !this.channel.Actor.serverId) {
+      throw new Error('Cannot check old channel/new channel validity because `serverId` is null')
+    }
+
+    if (this.oldVideoChannel.Actor.serverId !== this.channel.Actor.serverId) {
+      throw new Error(`New channel ${this.channel.Actor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.url}`)
+    }
+  }
+
+  private updateVideo (transaction: Transaction) {
+    const to = this.overrideTo || this.videoObject.to
+    const videoData = getVideoAttributesFromObject(this.channel, this.videoObject, to)
+    this.video.name = videoData.name
+    this.video.uuid = videoData.uuid
+    this.video.url = videoData.url
+    this.video.category = videoData.category
+    this.video.licence = videoData.licence
+    this.video.language = videoData.language
+    this.video.description = videoData.description
+    this.video.support = videoData.support
+    this.video.nsfw = videoData.nsfw
+    this.video.commentsEnabled = videoData.commentsEnabled
+    this.video.downloadEnabled = videoData.downloadEnabled
+    this.video.waitTranscoding = videoData.waitTranscoding
+    this.video.state = videoData.state
+    this.video.duration = videoData.duration
+    this.video.createdAt = videoData.createdAt
+    this.video.publishedAt = videoData.publishedAt
+    this.video.originallyPublishedAt = videoData.originallyPublishedAt
+    this.video.privacy = videoData.privacy
+    this.video.channelId = videoData.channelId
+    this.video.views = videoData.views
+    this.video.isLive = videoData.isLive
+
+    // Ensures we update the updated video attribute
+    this.video.changed('updatedAt', true)
+
+    return this.video.save({ transaction }) as Promise<MVideoFullLight>
+  }
+
+  private async setCaptions (videoUpdated: MVideoFullLight, t: Transaction) {
+    await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoUpdated.id, t)
+
+    await this.insertOrReplaceCaptions(videoUpdated, t)
+  }
+
+  private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction: Transaction) {
+    if (this.video.isLive) return this.insertOrReplaceLive(videoUpdated, transaction)
+
+    // Delete existing live if it exists
+    await VideoLiveModel.destroy({
+      where: {
+        videoId: this.video.id
+      },
+      transaction
+    })
+
+    videoUpdated.VideoLive = null
+  }
+
+  private catchUpdateError (err: Error) {
+    if (this.video !== undefined && this.videoFieldsSave !== undefined) {
+      resetSequelizeInstance(this.video, this.videoFieldsSave)
+    }
+
+    // This is just a debug because we will retry the insert
+    logger.debug('Cannot update the remote video.', { err })
+    throw err
+  }
+}
diff --git a/shared/extra-utils/videos/videos.ts b/shared/extra-utils/videos/videos.ts
index e88256ac0..98a568a02 100644
--- a/shared/extra-utils/videos/videos.ts
+++ b/shared/extra-utils/videos/videos.ts
@@ -774,9 +774,11 @@ async function completeVideoCheck (
     expect(torrent.files[0].path).to.exist.and.to.not.equal('')
   }
 
+  expect(videoDetails.thumbnailPath).to.exist
   await testImage(url, attributes.thumbnailfile || attributes.fixture, videoDetails.thumbnailPath)
 
   if (attributes.previewfile) {
+    expect(videoDetails.previewPath).to.exist
     await testImage(url, attributes.previewfile, videoDetails.previewPath)
   }
 }