Reduce video.ts file size by moving some methods in other files
authorChocobozzz <me@florianbigard.com>
Tue, 18 Sep 2018 09:02:51 +0000 (11:02 +0200)
committerChocobozzz <me@florianbigard.com>
Wed, 19 Sep 2018 07:54:35 +0000 (09:54 +0200)
config/test.yaml
server/lib/client-html.ts
server/lib/job-queue/handlers/video-file.ts
server/lib/video-transcoding.ts [new file with mode: 0644]
server/models/redundancy/video-redundancy.ts
server/models/video/video-format-utils.ts [new file with mode: 0644]
server/models/video/video.ts

index 16113211e0fcce9703339f15e2840a03684aa4c9..d3e0e49ac77b40d4e12c3d44811f76a41eaafdbc 100644 (file)
@@ -32,7 +32,7 @@ redundancy:
     -
       size: '10MB'
       strategy: 'recently-added'
-      minViews: 10
+      minViews: 1
 
 cache:
   previews:
index a69e09c32d8df849487d1768b5ed5ac7b9b63152..b1088c0968ca990578fd832db9300a9cbadfbc6a 100644 (file)
@@ -8,6 +8,7 @@ import { VideoModel } from '../models/video/video'
 import * as validator from 'validator'
 import { VideoPrivacy } from '../../shared/models/videos'
 import { readFile } from 'fs-extra'
+import { getActivityStreamDuration } from '../models/video/video-format-utils'
 
 export class ClientHtml {
 
@@ -150,7 +151,7 @@ export class ClientHtml {
       description: videoDescriptionEscaped,
       thumbnailUrl: previewUrl,
       uploadDate: video.createdAt.toISOString(),
-      duration: video.getActivityStreamDuration(),
+      duration: getActivityStreamDuration(video.duration),
       contentUrl: videoUrl,
       embedUrl: embedUrl,
       interactionCount: video.views
index c6308f7a6cbb4740c486844a0975d02ef199b5b7..2c9ca8e12383b7aed3a9becffe4091c642b97923 100644 (file)
@@ -8,6 +8,7 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { sequelizeTypescript } from '../../../initializers'
 import * as Bluebird from 'bluebird'
 import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
+import { importVideoFile, transcodeOriginalVideofile, optimizeOriginalVideofile } from '../../video-transcoding'
 
 export type VideoFilePayload = {
   videoUUID: string
@@ -32,7 +33,7 @@ async function processVideoFileImport (job: Bull.Job) {
     return undefined
   }
 
-  await video.importVideoFile(payload.filePath)
+  await importVideoFile(video, payload.filePath)
 
   await onVideoFileTranscoderOrImportSuccess(video)
   return video
@@ -51,11 +52,11 @@ async function processVideoFile (job: Bull.Job) {
 
   // Transcoding in other resolution
   if (payload.resolution) {
-    await video.transcodeOriginalVideofile(payload.resolution, payload.isPortraitMode || false)
+    await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false)
 
     await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video)
   } else {
-    await video.optimizeOriginalVideofile()
+    await optimizeOriginalVideofile(video)
 
     await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo)
   }
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts
new file mode 100644 (file)
index 0000000..bf3ff78
--- /dev/null
@@ -0,0 +1,130 @@
+import { CONFIG } from '../initializers'
+import { join, extname } from 'path'
+import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils'
+import { copy, remove, rename, stat } from 'fs-extra'
+import { logger } from '../helpers/logger'
+import { VideoResolution } from '../../shared/models/videos'
+import { VideoFileModel } from '../models/video/video-file'
+import { VideoModel } from '../models/video/video'
+
+async function optimizeOriginalVideofile (video: VideoModel) {
+  const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+  const newExtname = '.mp4'
+  const inputVideoFile = video.getOriginalFile()
+  const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile))
+  const videoTranscodedPath = join(videosDirectory, video.id + '-transcoded' + newExtname)
+
+  const transcodeOptions = {
+    inputPath: videoInputPath,
+    outputPath: videoTranscodedPath
+  }
+
+  // Could be very long!
+  await transcode(transcodeOptions)
+
+  try {
+    await remove(videoInputPath)
+
+    // Important to do this before getVideoFilename() to take in account the new file extension
+    inputVideoFile.set('extname', newExtname)
+
+    const videoOutputPath = video.getVideoFilePath(inputVideoFile)
+    await rename(videoTranscodedPath, videoOutputPath)
+    const stats = await stat(videoOutputPath)
+    const fps = await getVideoFileFPS(videoOutputPath)
+
+    inputVideoFile.set('size', stats.size)
+    inputVideoFile.set('fps', fps)
+
+    await video.createTorrentAndSetInfoHash(inputVideoFile)
+    await inputVideoFile.save()
+  } catch (err) {
+    // Auto destruction...
+    video.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
+
+    throw err
+  }
+}
+
+async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) {
+  const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+  const extname = '.mp4'
+
+  // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
+  const videoInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile()))
+
+  const newVideoFile = new VideoFileModel({
+    resolution,
+    extname,
+    size: 0,
+    videoId: video.id
+  })
+  const videoOutputPath = join(videosDirectory, video.getVideoFilename(newVideoFile))
+
+  const transcodeOptions = {
+    inputPath: videoInputPath,
+    outputPath: videoOutputPath,
+    resolution,
+    isPortraitMode
+  }
+
+  await transcode(transcodeOptions)
+
+  const stats = await stat(videoOutputPath)
+  const fps = await getVideoFileFPS(videoOutputPath)
+
+  newVideoFile.set('size', stats.size)
+  newVideoFile.set('fps', fps)
+
+  await video.createTorrentAndSetInfoHash(newVideoFile)
+
+  await newVideoFile.save()
+
+  video.VideoFiles.push(newVideoFile)
+}
+
+async function importVideoFile (video: VideoModel, inputFilePath: string) {
+  const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
+  const { size } = await stat(inputFilePath)
+  const fps = await getVideoFileFPS(inputFilePath)
+
+  let updatedVideoFile = new VideoFileModel({
+    resolution: videoFileResolution,
+    extname: extname(inputFilePath),
+    size,
+    fps,
+    videoId: video.id
+  })
+
+  const currentVideoFile = video.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
+
+  if (currentVideoFile) {
+    // Remove old file and old torrent
+    await video.removeFile(currentVideoFile)
+    await video.removeTorrent(currentVideoFile)
+    // Remove the old video file from the array
+    video.VideoFiles = video.VideoFiles.filter(f => f !== currentVideoFile)
+
+    // Update the database
+    currentVideoFile.set('extname', updatedVideoFile.extname)
+    currentVideoFile.set('size', updatedVideoFile.size)
+    currentVideoFile.set('fps', updatedVideoFile.fps)
+
+    updatedVideoFile = currentVideoFile
+  }
+
+  const outputPath = video.getVideoFilePath(updatedVideoFile)
+  await copy(inputFilePath, outputPath)
+
+  await video.createTorrentAndSetInfoHash(updatedVideoFile)
+
+  await updatedVideoFile.save()
+
+  video.VideoFiles.push(updatedVideoFile)
+}
+
+export {
+  optimizeOriginalVideofile,
+  transcodeOriginalVideofile,
+  importVideoFile
+}
index 6ae02efb988987af002f28039042b1f21f7b1276..fb07287a84c0f516da91cdebe372cfed77d65324 100644 (file)
@@ -193,7 +193,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
     // On VideoModel!
     const query = {
       attributes: [ 'id', 'publishedAt' ],
-      // logging: !isTestInstance(),
+      logging: !isTestInstance(),
       limit: randomizedFactor,
       order: getVideoSort('-publishedAt'),
       where: {
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
new file mode 100644 (file)
index 0000000..fae3850
--- /dev/null
@@ -0,0 +1,295 @@
+import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
+import { VideoModel } from './video'
+import { VideoFileModel } from './video-file'
+import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects'
+import { CONFIG, THUMBNAILS_SIZE, VIDEO_EXT_MIMETYPE } from '../../initializers'
+import { VideoCaptionModel } from './video-caption'
+import {
+  getVideoCommentsActivityPubUrl,
+  getVideoDislikesActivityPubUrl,
+  getVideoLikesActivityPubUrl,
+  getVideoSharesActivityPubUrl
+} from '../../lib/activitypub'
+
+export type VideoFormattingJSONOptions = {
+  additionalAttributes: {
+    state?: boolean,
+    waitTranscoding?: boolean,
+    scheduledUpdate?: boolean,
+    blacklistInfo?: boolean
+  }
+}
+function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
+  const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
+  const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
+
+  const videoObject: Video = {
+    id: video.id,
+    uuid: video.uuid,
+    name: video.name,
+    category: {
+      id: video.category,
+      label: VideoModel.getCategoryLabel(video.category)
+    },
+    licence: {
+      id: video.licence,
+      label: VideoModel.getLicenceLabel(video.licence)
+    },
+    language: {
+      id: video.language,
+      label: VideoModel.getLanguageLabel(video.language)
+    },
+    privacy: {
+      id: video.privacy,
+      label: VideoModel.getPrivacyLabel(video.privacy)
+    },
+    nsfw: video.nsfw,
+    description: video.getTruncatedDescription(),
+    isLocal: video.isOwned(),
+    duration: video.duration,
+    views: video.views,
+    likes: video.likes,
+    dislikes: video.dislikes,
+    thumbnailPath: video.getThumbnailStaticPath(),
+    previewPath: video.getPreviewStaticPath(),
+    embedPath: video.getEmbedStaticPath(),
+    createdAt: video.createdAt,
+    updatedAt: video.updatedAt,
+    publishedAt: video.publishedAt,
+    account: {
+      id: formattedAccount.id,
+      uuid: formattedAccount.uuid,
+      name: formattedAccount.name,
+      displayName: formattedAccount.displayName,
+      url: formattedAccount.url,
+      host: formattedAccount.host,
+      avatar: formattedAccount.avatar
+    },
+    channel: {
+      id: formattedVideoChannel.id,
+      uuid: formattedVideoChannel.uuid,
+      name: formattedVideoChannel.name,
+      displayName: formattedVideoChannel.displayName,
+      url: formattedVideoChannel.url,
+      host: formattedVideoChannel.host,
+      avatar: formattedVideoChannel.avatar
+    }
+  }
+
+  if (options) {
+    if (options.additionalAttributes.state === true) {
+      videoObject.state = {
+        id: video.state,
+        label: VideoModel.getStateLabel(video.state)
+      }
+    }
+
+    if (options.additionalAttributes.waitTranscoding === true) {
+      videoObject.waitTranscoding = video.waitTranscoding
+    }
+
+    if (options.additionalAttributes.scheduledUpdate === true && video.ScheduleVideoUpdate) {
+      videoObject.scheduledUpdate = {
+        updateAt: video.ScheduleVideoUpdate.updateAt,
+        privacy: video.ScheduleVideoUpdate.privacy || undefined
+      }
+    }
+
+    if (options.additionalAttributes.blacklistInfo === true) {
+      videoObject.blacklisted = !!video.VideoBlacklist
+      videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
+    }
+  }
+
+  return videoObject
+}
+
+function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
+  const formattedJson = video.toFormattedJSON({
+    additionalAttributes: {
+      scheduledUpdate: true,
+      blacklistInfo: true
+    }
+  })
+
+  const detailsJson = {
+    support: video.support,
+    descriptionPath: video.getDescriptionPath(),
+    channel: video.VideoChannel.toFormattedJSON(),
+    account: video.VideoChannel.Account.toFormattedJSON(),
+    tags: video.Tags.map(t => t.name),
+    commentsEnabled: video.commentsEnabled,
+    waitTranscoding: video.waitTranscoding,
+    state: {
+      id: video.state,
+      label: VideoModel.getStateLabel(video.state)
+    },
+    files: []
+  }
+
+  // Format and sort video files
+  detailsJson.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
+
+  return Object.assign(formattedJson, detailsJson)
+}
+
+function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
+  const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
+
+  return videoFiles
+    .map(videoFile => {
+      let resolutionLabel = videoFile.resolution + 'p'
+
+      return {
+        resolution: {
+          id: videoFile.resolution,
+          label: resolutionLabel
+        },
+        magnetUri: video.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
+        size: videoFile.size,
+        fps: videoFile.fps,
+        torrentUrl: video.getTorrentUrl(videoFile, baseUrlHttp),
+        torrentDownloadUrl: video.getTorrentDownloadUrl(videoFile, baseUrlHttp),
+        fileUrl: video.getVideoFileUrl(videoFile, baseUrlHttp),
+        fileDownloadUrl: video.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
+      } as VideoFile
+    })
+    .sort((a, b) => {
+      if (a.resolution.id < b.resolution.id) return 1
+      if (a.resolution.id === b.resolution.id) return 0
+      return -1
+    })
+}
+
+function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
+  const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
+  if (!video.Tags) video.Tags = []
+
+  const tag = video.Tags.map(t => ({
+    type: 'Hashtag' as 'Hashtag',
+    name: t.name
+  }))
+
+  let language
+  if (video.language) {
+    language = {
+      identifier: video.language,
+      name: VideoModel.getLanguageLabel(video.language)
+    }
+  }
+
+  let category
+  if (video.category) {
+    category = {
+      identifier: video.category + '',
+      name: VideoModel.getCategoryLabel(video.category)
+    }
+  }
+
+  let licence
+  if (video.licence) {
+    licence = {
+      identifier: video.licence + '',
+      name: VideoModel.getLicenceLabel(video.licence)
+    }
+  }
+
+  const url: ActivityUrlObject[] = []
+  for (const file of video.VideoFiles) {
+    url.push({
+      type: 'Link',
+      mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
+      href: video.getVideoFileUrl(file, baseUrlHttp),
+      height: file.resolution,
+      size: file.size,
+      fps: file.fps
+    })
+
+    url.push({
+      type: 'Link',
+      mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
+      href: video.getTorrentUrl(file, baseUrlHttp),
+      height: file.resolution
+    })
+
+    url.push({
+      type: 'Link',
+      mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
+      href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
+      height: file.resolution
+    })
+  }
+
+  // Add video url too
+  url.push({
+    type: 'Link',
+    mimeType: 'text/html',
+    href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
+  })
+
+  const subtitleLanguage = []
+  for (const caption of video.VideoCaptions) {
+    subtitleLanguage.push({
+      identifier: caption.language,
+      name: VideoCaptionModel.getLanguageLabel(caption.language)
+    })
+  }
+
+  return {
+    type: 'Video' as 'Video',
+    id: video.url,
+    name: video.name,
+    duration: getActivityStreamDuration(video.duration),
+    uuid: video.uuid,
+    tag,
+    category,
+    licence,
+    language,
+    views: video.views,
+    sensitive: video.nsfw,
+    waitTranscoding: video.waitTranscoding,
+    state: video.state,
+    commentsEnabled: video.commentsEnabled,
+    published: video.publishedAt.toISOString(),
+    updated: video.updatedAt.toISOString(),
+    mediaType: 'text/markdown',
+    content: video.getTruncatedDescription(),
+    support: video.support,
+    subtitleLanguage,
+    icon: {
+      type: 'Image',
+      url: video.getThumbnailUrl(baseUrlHttp),
+      mediaType: 'image/jpeg',
+      width: THUMBNAILS_SIZE.width,
+      height: THUMBNAILS_SIZE.height
+    },
+    url,
+    likes: getVideoLikesActivityPubUrl(video),
+    dislikes: getVideoDislikesActivityPubUrl(video),
+    shares: getVideoSharesActivityPubUrl(video),
+    comments: getVideoCommentsActivityPubUrl(video),
+    attributedTo: [
+      {
+        type: 'Person',
+        id: video.VideoChannel.Account.Actor.url
+      },
+      {
+        type: 'Group',
+        id: video.VideoChannel.Actor.url
+      }
+    ]
+  }
+}
+
+function getActivityStreamDuration (duration: number) {
+  // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
+  return 'PT' + duration + 'S'
+}
+
+export {
+  videoModelToFormattedJSON,
+  videoModelToFormattedDetailsJSON,
+  videoFilesModelToFormattedJSON,
+  videoModelToActivityPubObject,
+  getActivityStreamDuration
+}
index b7d3f184f11531779b663696df257e51249573af..ce856aed2c9ce7926e2ce3a0f5b562ba3494a98a 100644 (file)
@@ -1,8 +1,8 @@
 import * as Bluebird from 'bluebird'
-import { map, maxBy } from 'lodash'
+import { maxBy } from 'lodash'
 import * as magnetUtil from 'magnet-uri'
 import * as parseTorrent from 'parse-torrent'
-import { extname, join } from 'path'
+import { join } from 'path'
 import * as Sequelize from 'sequelize'
 import {
   AllowNull,
@@ -27,7 +27,7 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { ActivityUrlObject, VideoPrivacy, VideoResolution, VideoState } from '../../../shared'
+import { VideoPrivacy, VideoState } from '../../../shared'
 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
 import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
 import { VideoFilter } from '../../../shared/models/videos/video-query.type'
@@ -45,7 +45,7 @@ import {
   isVideoStateValid,
   isVideoSupportValid
 } from '../../helpers/custom-validators/videos'
-import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
+import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils'
 import { logger } from '../../helpers/logger'
 import { getServerActor } from '../../helpers/utils'
 import {
@@ -59,18 +59,11 @@ import {
   STATIC_PATHS,
   THUMBNAILS_SIZE,
   VIDEO_CATEGORIES,
-  VIDEO_EXT_MIMETYPE,
   VIDEO_LANGUAGES,
   VIDEO_LICENCES,
   VIDEO_PRIVACIES,
   VIDEO_STATES
 } from '../../initializers'
-import {
-  getVideoCommentsActivityPubUrl,
-  getVideoDislikesActivityPubUrl,
-  getVideoLikesActivityPubUrl,
-  getVideoSharesActivityPubUrl
-} from '../../lib/activitypub'
 import { sendDeleteVideo } from '../../lib/activitypub/send'
 import { AccountModel } from '../account/account'
 import { AccountVideoRateModel } from '../account/account-video-rate'
@@ -88,9 +81,16 @@ import { VideoTagModel } from './video-tag'
 import { ScheduleVideoUpdateModel } from './schedule-video-update'
 import { VideoCaptionModel } from './video-caption'
 import { VideoBlacklistModel } from './video-blacklist'
-import { copy, remove, rename, stat, writeFile } from 'fs-extra'
+import { remove, writeFile } from 'fs-extra'
 import { VideoViewModel } from './video-views'
 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
+import {
+  videoFilesModelToFormattedJSON,
+  VideoFormattingJSONOptions,
+  videoModelToActivityPubObject,
+  videoModelToFormattedDetailsJSON,
+  videoModelToFormattedJSON
+} from './video-format-utils'
 
 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
 const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -1257,23 +1257,23 @@ export class VideoModel extends Model<VideoModel> {
     }
   }
 
-  private static getCategoryLabel (id: number) {
+  static getCategoryLabel (id: number) {
     return VIDEO_CATEGORIES[ id ] || 'Misc'
   }
 
-  private static getLicenceLabel (id: number) {
+  static getLicenceLabel (id: number) {
     return VIDEO_LICENCES[ id ] || 'Unknown'
   }
 
-  private static getLanguageLabel (id: string) {
+  static getLanguageLabel (id: string) {
     return VIDEO_LANGUAGES[ id ] || 'Unknown'
   }
 
-  private static getPrivacyLabel (id: number) {
+  static getPrivacyLabel (id: number) {
     return VIDEO_PRIVACIES[ id ] || 'Unknown'
   }
 
-  private static getStateLabel (id: number) {
+  static getStateLabel (id: number) {
     return VIDEO_STATES[ id ] || 'Unknown'
   }
 
@@ -1369,273 +1369,20 @@ export class VideoModel extends Model<VideoModel> {
     return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
   }
 
-  toFormattedJSON (options?: {
-    additionalAttributes: {
-      state?: boolean,
-      waitTranscoding?: boolean,
-      scheduledUpdate?: boolean,
-      blacklistInfo?: boolean
-    }
-  }): Video {
-    const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
-    const formattedVideoChannel = this.VideoChannel.toFormattedJSON()
-
-    const videoObject: Video = {
-      id: this.id,
-      uuid: this.uuid,
-      name: this.name,
-      category: {
-        id: this.category,
-        label: VideoModel.getCategoryLabel(this.category)
-      },
-      licence: {
-        id: this.licence,
-        label: VideoModel.getLicenceLabel(this.licence)
-      },
-      language: {
-        id: this.language,
-        label: VideoModel.getLanguageLabel(this.language)
-      },
-      privacy: {
-        id: this.privacy,
-        label: VideoModel.getPrivacyLabel(this.privacy)
-      },
-      nsfw: this.nsfw,
-      description: this.getTruncatedDescription(),
-      isLocal: this.isOwned(),
-      duration: this.duration,
-      views: this.views,
-      likes: this.likes,
-      dislikes: this.dislikes,
-      thumbnailPath: this.getThumbnailStaticPath(),
-      previewPath: this.getPreviewStaticPath(),
-      embedPath: this.getEmbedStaticPath(),
-      createdAt: this.createdAt,
-      updatedAt: this.updatedAt,
-      publishedAt: this.publishedAt,
-      account: {
-        id: formattedAccount.id,
-        uuid: formattedAccount.uuid,
-        name: formattedAccount.name,
-        displayName: formattedAccount.displayName,
-        url: formattedAccount.url,
-        host: formattedAccount.host,
-        avatar: formattedAccount.avatar
-      },
-      channel: {
-        id: formattedVideoChannel.id,
-        uuid: formattedVideoChannel.uuid,
-        name: formattedVideoChannel.name,
-        displayName: formattedVideoChannel.displayName,
-        url: formattedVideoChannel.url,
-        host: formattedVideoChannel.host,
-        avatar: formattedVideoChannel.avatar
-      }
-    }
-
-    if (options) {
-      if (options.additionalAttributes.state === true) {
-        videoObject.state = {
-          id: this.state,
-          label: VideoModel.getStateLabel(this.state)
-        }
-      }
-
-      if (options.additionalAttributes.waitTranscoding === true) {
-        videoObject.waitTranscoding = this.waitTranscoding
-      }
-
-      if (options.additionalAttributes.scheduledUpdate === true && this.ScheduleVideoUpdate) {
-        videoObject.scheduledUpdate = {
-          updateAt: this.ScheduleVideoUpdate.updateAt,
-          privacy: this.ScheduleVideoUpdate.privacy || undefined
-        }
-      }
-
-      if (options.additionalAttributes.blacklistInfo === true) {
-        videoObject.blacklisted = !!this.VideoBlacklist
-        videoObject.blacklistedReason = this.VideoBlacklist ? this.VideoBlacklist.reason : null
-      }
-    }
-
-    return videoObject
+  toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
+    return videoModelToFormattedJSON(this, options)
   }
 
   toFormattedDetailsJSON (): VideoDetails {
-    const formattedJson = this.toFormattedJSON({
-      additionalAttributes: {
-        scheduledUpdate: true,
-        blacklistInfo: true
-      }
-    })
-
-    const detailsJson = {
-      support: this.support,
-      descriptionPath: this.getDescriptionPath(),
-      channel: this.VideoChannel.toFormattedJSON(),
-      account: this.VideoChannel.Account.toFormattedJSON(),
-      tags: map(this.Tags, 'name'),
-      commentsEnabled: this.commentsEnabled,
-      waitTranscoding: this.waitTranscoding,
-      state: {
-        id: this.state,
-        label: VideoModel.getStateLabel(this.state)
-      },
-      files: []
-    }
-
-    // Format and sort video files
-    detailsJson.files = this.getFormattedVideoFilesJSON()
-
-    return Object.assign(formattedJson, detailsJson)
+    return videoModelToFormattedDetailsJSON(this)
   }
 
   getFormattedVideoFilesJSON (): VideoFile[] {
-    const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
-
-    return this.VideoFiles
-               .map(videoFile => {
-                 let resolutionLabel = videoFile.resolution + 'p'
-
-                 return {
-                   resolution: {
-                     id: videoFile.resolution,
-                     label: resolutionLabel
-                   },
-                   magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
-                   size: videoFile.size,
-                   fps: videoFile.fps,
-                   torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
-                   torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
-                   fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
-                   fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
-                 } as VideoFile
-               })
-               .sort((a, b) => {
-                 if (a.resolution.id < b.resolution.id) return 1
-                 if (a.resolution.id === b.resolution.id) return 0
-                 return -1
-               })
+    return videoFilesModelToFormattedJSON(this, this.VideoFiles)
   }
 
   toActivityPubObject (): VideoTorrentObject {
-    const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
-    if (!this.Tags) this.Tags = []
-
-    const tag = this.Tags.map(t => ({
-      type: 'Hashtag' as 'Hashtag',
-      name: t.name
-    }))
-
-    let language
-    if (this.language) {
-      language = {
-        identifier: this.language,
-        name: VideoModel.getLanguageLabel(this.language)
-      }
-    }
-
-    let category
-    if (this.category) {
-      category = {
-        identifier: this.category + '',
-        name: VideoModel.getCategoryLabel(this.category)
-      }
-    }
-
-    let licence
-    if (this.licence) {
-      licence = {
-        identifier: this.licence + '',
-        name: VideoModel.getLicenceLabel(this.licence)
-      }
-    }
-
-    const url: ActivityUrlObject[] = []
-    for (const file of this.VideoFiles) {
-      url.push({
-        type: 'Link',
-        mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
-        href: this.getVideoFileUrl(file, baseUrlHttp),
-        height: file.resolution,
-        size: file.size,
-        fps: file.fps
-      })
-
-      url.push({
-        type: 'Link',
-        mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
-        href: this.getTorrentUrl(file, baseUrlHttp),
-        height: file.resolution
-      })
-
-      url.push({
-        type: 'Link',
-        mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
-        href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
-        height: file.resolution
-      })
-    }
-
-    // Add video url too
-    url.push({
-      type: 'Link',
-      mimeType: 'text/html',
-      href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
-    })
-
-    const subtitleLanguage = []
-    for (const caption of this.VideoCaptions) {
-      subtitleLanguage.push({
-        identifier: caption.language,
-        name: VideoCaptionModel.getLanguageLabel(caption.language)
-      })
-    }
-
-    return {
-      type: 'Video' as 'Video',
-      id: this.url,
-      name: this.name,
-      duration: this.getActivityStreamDuration(),
-      uuid: this.uuid,
-      tag,
-      category,
-      licence,
-      language,
-      views: this.views,
-      sensitive: this.nsfw,
-      waitTranscoding: this.waitTranscoding,
-      state: this.state,
-      commentsEnabled: this.commentsEnabled,
-      published: this.publishedAt.toISOString(),
-      updated: this.updatedAt.toISOString(),
-      mediaType: 'text/markdown',
-      content: this.getTruncatedDescription(),
-      support: this.support,
-      subtitleLanguage,
-      icon: {
-        type: 'Image',
-        url: this.getThumbnailUrl(baseUrlHttp),
-        mediaType: 'image/jpeg',
-        width: THUMBNAILS_SIZE.width,
-        height: THUMBNAILS_SIZE.height
-      },
-      url,
-      likes: getVideoLikesActivityPubUrl(this),
-      dislikes: getVideoDislikesActivityPubUrl(this),
-      shares: getVideoSharesActivityPubUrl(this),
-      comments: getVideoCommentsActivityPubUrl(this),
-      attributedTo: [
-        {
-          type: 'Person',
-          id: this.VideoChannel.Account.Actor.url
-        },
-        {
-          type: 'Group',
-          id: this.VideoChannel.Actor.url
-        }
-      ]
-    }
+    return videoModelToActivityPubObject(this)
   }
 
   getTruncatedDescription () {
@@ -1645,123 +1392,6 @@ export class VideoModel extends Model<VideoModel> {
     return peertubeTruncate(this.description, maxLength)
   }
 
-  async optimizeOriginalVideofile () {
-    const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
-    const newExtname = '.mp4'
-    const inputVideoFile = this.getOriginalFile()
-    const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
-    const videoTranscodedPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
-
-    const transcodeOptions = {
-      inputPath: videoInputPath,
-      outputPath: videoTranscodedPath
-    }
-
-    // Could be very long!
-    await transcode(transcodeOptions)
-
-    try {
-      await remove(videoInputPath)
-
-      // Important to do this before getVideoFilename() to take in account the new file extension
-      inputVideoFile.set('extname', newExtname)
-
-      const videoOutputPath = this.getVideoFilePath(inputVideoFile)
-      await rename(videoTranscodedPath, videoOutputPath)
-      const stats = await stat(videoOutputPath)
-      const fps = await getVideoFileFPS(videoOutputPath)
-
-      inputVideoFile.set('size', stats.size)
-      inputVideoFile.set('fps', fps)
-
-      await this.createTorrentAndSetInfoHash(inputVideoFile)
-      await inputVideoFile.save()
-
-    } catch (err) {
-      // Auto destruction...
-      this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
-
-      throw err
-    }
-  }
-
-  async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
-    const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
-    const extname = '.mp4'
-
-    // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
-    const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
-
-    const newVideoFile = new VideoFileModel({
-      resolution,
-      extname,
-      size: 0,
-      videoId: this.id
-    })
-    const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
-
-    const transcodeOptions = {
-      inputPath: videoInputPath,
-      outputPath: videoOutputPath,
-      resolution,
-      isPortraitMode
-    }
-
-    await transcode(transcodeOptions)
-
-    const stats = await stat(videoOutputPath)
-    const fps = await getVideoFileFPS(videoOutputPath)
-
-    newVideoFile.set('size', stats.size)
-    newVideoFile.set('fps', fps)
-
-    await this.createTorrentAndSetInfoHash(newVideoFile)
-
-    await newVideoFile.save()
-
-    this.VideoFiles.push(newVideoFile)
-  }
-
-  async importVideoFile (inputFilePath: string) {
-    const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
-    const { size } = await stat(inputFilePath)
-    const fps = await getVideoFileFPS(inputFilePath)
-
-    let updatedVideoFile = new VideoFileModel({
-      resolution: videoFileResolution,
-      extname: extname(inputFilePath),
-      size,
-      fps,
-      videoId: this.id
-    })
-
-    const currentVideoFile = this.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
-
-    if (currentVideoFile) {
-      // Remove old file and old torrent
-      await this.removeFile(currentVideoFile)
-      await this.removeTorrent(currentVideoFile)
-      // Remove the old video file from the array
-      this.VideoFiles = this.VideoFiles.filter(f => f !== currentVideoFile)
-
-      // Update the database
-      currentVideoFile.set('extname', updatedVideoFile.extname)
-      currentVideoFile.set('size', updatedVideoFile.size)
-      currentVideoFile.set('fps', updatedVideoFile.fps)
-
-      updatedVideoFile = currentVideoFile
-    }
-
-    const outputPath = this.getVideoFilePath(updatedVideoFile)
-    await copy(inputFilePath, outputPath)
-
-    await this.createTorrentAndSetInfoHash(updatedVideoFile)
-
-    await updatedVideoFile.save()
-
-    this.VideoFiles.push(updatedVideoFile)
-  }
-
   getOriginalFileResolution () {
     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
 
@@ -1796,11 +1426,6 @@ export class VideoModel extends Model<VideoModel> {
       .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
   }
 
-  getActivityStreamDuration () {
-    // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
-    return 'PT' + this.duration + 'S'
-  }
-
   isOutdated () {
     if (this.isOwned()) return false