Browse Source

Create a dedicated table to track video thumbnails

Chocobozzz 5 years ago
parent
commit
e8bafea35b

+ 0 - 2
server.ts

@@ -255,8 +255,6 @@ async function startApplication () {
 
   // Make server listening
   server.listen(port, hostname, () => {
-    logger.debug('CONFIG', { CONFIG })
-
     logger.info('Server listening on %s:%d', hostname, port)
     logger.info('Web server: %s', WEBSERVER.URL)
   })

+ 25 - 22
server/controllers/api/video-playlist.ts

@@ -12,7 +12,7 @@ import {
 } from '../../middlewares'
 import { videoPlaylistsSortValidator } from '../../middlewares/validators'
 import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
-import { MIMETYPES, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants'
+import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants'
 import { logger } from '../../helpers/logger'
 import { resetSequelizeInstance } from '../../helpers/database-utils'
 import { VideoPlaylistModel } from '../../models/video/video-playlist'
@@ -28,7 +28,6 @@ import {
 } from '../../middlewares/validators/videos/video-playlists'
 import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
 import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
-import { processImage } from '../../helpers/image-utils'
 import { join } from 'path'
 import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
 import { getVideoPlaylistActivityPubUrl, getVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
@@ -37,12 +36,12 @@ import { VideoModel } from '../../models/video/video'
 import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
 import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
 import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
-import { copy, pathExists } from 'fs-extra'
 import { AccountModel } from '../../models/account/account'
 import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model'
 import { JobQueue } from '../../lib/job-queue'
 import { CONFIG } from '../../initializers/config'
 import { sequelizeTypescript } from '../../initializers/database'
+import { createPlaylistThumbnailFromExisting } from '../../lib/thumbnail'
 
 const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
 
@@ -174,14 +173,18 @@ async function addVideoPlaylist (req: express.Request, res: express.Response) {
   }
 
   const thumbnailField = req.files['thumbnailfile']
-  if (thumbnailField) {
-    const thumbnailPhysicalFile = thumbnailField[ 0 ]
-    await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()), THUMBNAILS_SIZE)
-  }
+  const thumbnailModel = thumbnailField
+    ? await createPlaylistThumbnailFromExisting(thumbnailField[0].path, videoPlaylist)
+    : undefined
 
   const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => {
     const videoPlaylistCreated = await videoPlaylist.save({ transaction: t })
 
+    if (thumbnailModel) {
+      thumbnailModel.videoPlaylistId = videoPlaylistCreated.id
+      videoPlaylistCreated.setThumbnail(await thumbnailModel.save({ transaction: t }))
+    }
+
     // We need more attributes for the federation
     videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t)
     await sendCreateVideoPlaylist(videoPlaylistCreated, t)
@@ -206,14 +209,9 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
   const wasPrivatePlaylist = videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE
 
   const thumbnailField = req.files['thumbnailfile']
-  if (thumbnailField) {
-    const thumbnailPhysicalFile = thumbnailField[ 0 ]
-    await processImage(
-      thumbnailPhysicalFile,
-      join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylistInstance.getThumbnailName()),
-      THUMBNAILS_SIZE
-    )
-  }
+  const thumbnailModel = thumbnailField
+    ? await createPlaylistThumbnailFromExisting(thumbnailField[0].path, videoPlaylistInstance)
+    : undefined
 
   try {
     await sequelizeTypescript.transaction(async t => {
@@ -241,6 +239,11 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
 
       const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions)
 
+      if (thumbnailModel) {
+        thumbnailModel.videoPlaylistId = playlistUpdated.id
+        playlistUpdated.setThumbnail(await thumbnailModel.save({ transaction: t }))
+      }
+
       const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE
 
       if (isNewPlaylist) {
@@ -307,15 +310,15 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response)
   })
 
   // If the user did not set a thumbnail, automatically take the video thumbnail
-  if (playlistElement.position === 1) {
-    const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName())
+  if (playlistElement.position === 1 && videoPlaylist.hasThumbnail() === false) {
+    logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
 
-    if (await pathExists(playlistThumbnailPath) === false) {
-      logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
+    const inputPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnail().filename)
+    const thumbnailModel = await createPlaylistThumbnailFromExisting(inputPath, videoPlaylist, true)
 
-      const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
-      await copy(videoThumbnailPath, playlistThumbnailPath)
-    }
+    thumbnailModel.videoPlaylistId = videoPlaylist.id
+
+    await thumbnailModel.save()
   }
 
   logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)

+ 48 - 22
server/controllers/api/videos/import.ts

@@ -3,7 +3,7 @@ import * as magnetUtil from 'magnet-uri'
 import 'multer'
 import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
 import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
-import { MIMETYPES, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../../../initializers/constants'
+import { MIMETYPES } from '../../../initializers/constants'
 import { getYoutubeDLInfo, YoutubeDLInfo } from '../../../helpers/youtube-dl'
 import { createReqFiles } from '../../../helpers/express-utils'
 import { logger } from '../../../helpers/logger'
@@ -13,12 +13,10 @@ import { getVideoActivityPubUrl } from '../../../lib/activitypub'
 import { TagModel } from '../../../models/video/tag'
 import { VideoImportModel } from '../../../models/video/video-import'
 import { JobQueue } from '../../../lib/job-queue/job-queue'
-import { processImage } from '../../../helpers/image-utils'
 import { join } from 'path'
 import { isArray } from '../../../helpers/custom-validators/misc'
 import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
 import { VideoChannelModel } from '../../../models/video/video-channel'
-import { UserModel } from '../../../models/account/user'
 import * as Bluebird from 'bluebird'
 import * as parseTorrent from 'parse-torrent'
 import { getSecureTorrentName } from '../../../helpers/utils'
@@ -26,6 +24,9 @@ import { move, readFile } from 'fs-extra'
 import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
 import { CONFIG } from '../../../initializers/config'
 import { sequelizeTypescript } from '../../../initializers/database'
+import { createVideoThumbnailFromExisting } from '../../../lib/thumbnail'
+import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
+import { ThumbnailModel } from '../../../models/video/thumbnail'
 
 const auditLogger = auditLoggerFactory('video-imports')
 const videoImportsRouter = express.Router()
@@ -89,10 +90,10 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
     videoName = isArray(parsed.name) ? parsed.name[ 0 ] : parsed.name as string
   }
 
-  const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName }, user)
+  const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName })
 
-  await processThumbnail(req, video)
-  await processPreview(req, video)
+  const thumbnailModel = await processThumbnail(req, video)
+  const previewModel = await processPreview(req, video)
 
   const tags = body.tags || undefined
   const videoImportAttributes = {
@@ -101,7 +102,14 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
     state: VideoImportState.PENDING,
     userId: user.id
   }
-  const videoImport = await insertIntoDB(video, res.locals.videoChannel, tags, videoImportAttributes)
+  const videoImport = await insertIntoDB({
+    video,
+    thumbnailModel,
+    previewModel,
+    videoChannel: res.locals.videoChannel,
+    tags,
+    videoImportAttributes
+  })
 
   // Create job to import the video
   const payload = {
@@ -132,10 +140,10 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
     }).end()
   }
 
-  const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo, user)
+  const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo)
 
-  const downloadThumbnail = !await processThumbnail(req, video)
-  const downloadPreview = !await processPreview(req, video)
+  const thumbnailModel = await processThumbnail(req, video)
+  const previewModel = await processPreview(req, video)
 
   const tags = body.tags || youtubeDLInfo.tags
   const videoImportAttributes = {
@@ -143,15 +151,22 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
     state: VideoImportState.PENDING,
     userId: user.id
   }
-  const videoImport = await insertIntoDB(video, res.locals.videoChannel, tags, videoImportAttributes)
+  const videoImport = await insertIntoDB({
+    video: video,
+    thumbnailModel,
+    previewModel,
+    videoChannel: res.locals.videoChannel,
+    tags,
+    videoImportAttributes
+  })
 
   // Create job to import the video
   const payload = {
     type: 'youtube-dl' as 'youtube-dl',
     videoImportId: videoImport.id,
     thumbnailUrl: youtubeDLInfo.thumbnailUrl,
-    downloadThumbnail,
-    downloadPreview
+    downloadThumbnail: !thumbnailModel,
+    downloadPreview: !previewModel
   }
   await JobQueue.Instance.createJob({ type: 'video-import', payload })
 
@@ -160,7 +175,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
   return res.json(videoImport.toFormattedJSON()).end()
 }
 
-function buildVideo (channelId: number, body: VideoImportCreate, importData: YoutubeDLInfo, user: UserModel) {
+function buildVideo (channelId: number, body: VideoImportCreate, importData: YoutubeDLInfo) {
   const videoData = {
     name: body.name || importData.name || 'Unknown name',
     remote: false,
@@ -189,32 +204,34 @@ async function processThumbnail (req: express.Request, video: VideoModel) {
   const thumbnailField = req.files ? req.files['thumbnailfile'] : undefined
   if (thumbnailField) {
     const thumbnailPhysicalFile = thumbnailField[ 0 ]
-    await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()), THUMBNAILS_SIZE)
 
-    return true
+    return createVideoThumbnailFromExisting(thumbnailPhysicalFile.path, video, ThumbnailType.THUMBNAIL)
   }
 
-  return false
+  return undefined
 }
 
 async function processPreview (req: express.Request, video: VideoModel) {
   const previewField = req.files ? req.files['previewfile'] : undefined
   if (previewField) {
     const previewPhysicalFile = previewField[0]
-    await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()), PREVIEWS_SIZE)
 
-    return true
+    return createVideoThumbnailFromExisting(previewPhysicalFile.path, video, ThumbnailType.PREVIEW)
   }
 
-  return false
+  return undefined
 }
 
-function insertIntoDB (
+function insertIntoDB (parameters: {
   video: VideoModel,
+  thumbnailModel: ThumbnailModel,
+  previewModel: ThumbnailModel,
   videoChannel: VideoChannelModel,
   tags: string[],
   videoImportAttributes: FilteredModelAttributes<VideoImportModel>
-): Bluebird<VideoImportModel> {
+}): Bluebird<VideoImportModel> {
+  let { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes } = parameters
+
   return sequelizeTypescript.transaction(async t => {
     const sequelizeOptions = { transaction: t }
 
@@ -222,6 +239,15 @@ function insertIntoDB (
     const videoCreated = await video.save(sequelizeOptions)
     videoCreated.VideoChannel = videoChannel
 
+    if (thumbnailModel) {
+      thumbnailModel.videoId = videoCreated.id
+      videoCreated.addThumbnail(await thumbnailModel.save({ transaction: t }))
+    }
+    if (previewModel) {
+      previewModel.videoId = videoCreated.id
+      videoCreated.addThumbnail(await previewModel.save({ transaction: t }))
+    }
+
     await autoBlacklistVideoIfNeeded(video, videoChannel.Account.User, t)
 
     // Set tags to the video

+ 31 - 31
server/controllers/api/videos/index.ts

@@ -2,20 +2,11 @@ import * as express from 'express'
 import { extname, join } from 'path'
 import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared'
 import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
-import { processImage } from '../../../helpers/image-utils'
 import { logger } from '../../../helpers/logger'
 import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
 import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
 import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
-import {
-  MIMETYPES,
-  PREVIEWS_SIZE,
-  THUMBNAILS_SIZE,
-  VIDEO_CATEGORIES,
-  VIDEO_LANGUAGES,
-  VIDEO_LICENCES,
-  VIDEO_PRIVACIES
-} from '../../../initializers/constants'
+import { MIMETYPES, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants'
 import {
   changeVideoChannelShare,
   federateVideoIfNeeded,
@@ -61,6 +52,8 @@ import { Notifier } from '../../../lib/notifier'
 import { sendView } from '../../../lib/activitypub/send/send-view'
 import { CONFIG } from '../../../initializers/config'
 import { sequelizeTypescript } from '../../../initializers/database'
+import { createVideoThumbnailFromExisting, generateVideoThumbnail } from '../../../lib/thumbnail'
+import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
 
 const auditLogger = auditLoggerFactory('videos')
 const videosRouter = express.Router()
@@ -220,21 +213,15 @@ async function addVideo (req: express.Request, res: express.Response) {
 
   // Process thumbnail or create it from the video
   const thumbnailField = req.files['thumbnailfile']
-  if (thumbnailField) {
-    const thumbnailPhysicalFile = thumbnailField[0]
-    await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()), THUMBNAILS_SIZE)
-  } else {
-    await video.createThumbnail(videoFile)
-  }
+  const thumbnailModel = thumbnailField
+    ? await createVideoThumbnailFromExisting(thumbnailField[0].path, video, ThumbnailType.THUMBNAIL)
+    : await generateVideoThumbnail(video, videoFile, ThumbnailType.THUMBNAIL)
 
   // Process preview or create it from the video
   const previewField = req.files['previewfile']
-  if (previewField) {
-    const previewPhysicalFile = previewField[0]
-    await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()), PREVIEWS_SIZE)
-  } else {
-    await video.createPreview(videoFile)
-  }
+  const previewModel = previewField
+    ? await createVideoThumbnailFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW)
+    : await generateVideoThumbnail(video, videoFile, ThumbnailType.PREVIEW)
 
   // Create the torrent file
   await video.createTorrentAndSetInfoHash(videoFile)
@@ -243,6 +230,13 @@ async function addVideo (req: express.Request, res: express.Response) {
     const sequelizeOptions = { transaction: t }
 
     const videoCreated = await video.save(sequelizeOptions)
+
+    thumbnailModel.videoId = videoCreated.id
+    previewModel.videoId = videoCreated.id
+
+    videoCreated.addThumbnail(await thumbnailModel.save({ transaction: t }))
+    videoCreated.addThumbnail(await previewModel.save({ transaction: t }))
+
     // Do not forget to add video channel information to the created video
     videoCreated.VideoChannel = res.locals.videoChannel
 
@@ -313,16 +307,13 @@ async function updateVideo (req: express.Request, res: express.Response) {
   const wasUnlistedVideo = videoInstance.privacy === VideoPrivacy.UNLISTED
 
   // Process thumbnail or create it from the video
-  if (req.files && req.files['thumbnailfile']) {
-    const thumbnailPhysicalFile = req.files['thumbnailfile'][0]
-    await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, videoInstance.getThumbnailName()), THUMBNAILS_SIZE)
-  }
+  const thumbnailModel = req.files && req.files['thumbnailfile']
+    ? await createVideoThumbnailFromExisting(req.files['thumbnailfile'][0].path, videoInstance, ThumbnailType.THUMBNAIL)
+    : undefined
 
-  // Process preview or create it from the video
-  if (req.files && req.files['previewfile']) {
-    const previewPhysicalFile = req.files['previewfile'][0]
-    await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, videoInstance.getPreviewName()), PREVIEWS_SIZE)
-  }
+  const previewModel = req.files && req.files['previewfile']
+    ? await createVideoThumbnailFromExisting(req.files['previewfile'][0].path, videoInstance, ThumbnailType.PREVIEW)
+    : undefined
 
   try {
     const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => {
@@ -355,6 +346,15 @@ async function updateVideo (req: express.Request, res: express.Response) {
 
       const videoInstanceUpdated = await videoInstance.save(sequelizeOptions)
 
+      if (thumbnailModel) {
+        thumbnailModel.videoId = videoInstanceUpdated.id
+        videoInstanceUpdated.addThumbnail(await thumbnailModel.save({ transaction: t }))
+      }
+      if (previewModel) {
+        previewModel.videoId = videoInstanceUpdated.id
+        videoInstanceUpdated.addThumbnail(await previewModel.save({ transaction: t }))
+      }
+
       // Video tags update?
       if (videoInfoToUpdate.tags !== undefined) {
         const tagInstances = await TagModel.findOrCreateTags(videoInfoToUpdate.tags, t)

+ 0 - 1
server/controllers/api/videos/ownership.ts

@@ -17,7 +17,6 @@ import { VideoChannelModel } from '../../../models/video/video-channel'
 import { getFormattedObjects } from '../../../helpers/utils'
 import { changeVideoChannelShare } from '../../../lib/activitypub'
 import { sendUpdateVideo } from '../../../lib/activitypub/send'
-import { UserModel } from '../../../models/account/user'
 
 const ownershipVideoRouter = express.Router()
 

+ 1 - 1
server/controllers/static.ts

@@ -164,7 +164,7 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) {
+async function getPreview (req: express.Request, res: express.Response) {
   const path = await VideosPreviewCache.Instance.getFilePath(req.params.uuid)
   if (!path) return res.sendStatus(404)
 

+ 3 - 2
server/helpers/image-utils.ts

@@ -6,7 +6,8 @@ import { logger } from './logger'
 async function processImage (
   physicalFile: { path: string },
   destination: string,
-  newSize: { width: number, height: number }
+  newSize: { width: number, height: number },
+  keepOriginal = false
 ) {
   if (physicalFile.path === destination) {
     throw new Error('Sharp needs an input path different that the output path.')
@@ -24,7 +25,7 @@ async function processImage (
     .resize(newSize.width, newSize.height)
     .toFile(destination)
 
-  await remove(physicalFile.path)
+  if (keepOriginal !== true) await remove(physicalFile.path)
 }
 
 // ---------------------------------------------------------------------------

+ 3 - 1
server/initializers/database.ts

@@ -36,6 +36,7 @@ import { UserNotificationSettingModel } from '../models/account/user-notificatio
 import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
 import { VideoPlaylistModel } from '../models/video/video-playlist'
 import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
+import { ThumbnailModel } from '../models/video/thumbnail'
 
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -105,7 +106,8 @@ async function initDatabaseModels (silent: boolean) {
     UserNotificationSettingModel,
     VideoStreamingPlaylistModel,
     VideoPlaylistModel,
-    VideoPlaylistElementModel
+    VideoPlaylistElementModel,
+    ThumbnailModel
   ])
 
   // Check extensions exist in the database

+ 11 - 14
server/lib/activitypub/playlist.ts

@@ -1,12 +1,12 @@
 import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
 import { crawlCollectionPage } from './crawl'
-import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY, THUMBNAILS_SIZE } from '../../initializers/constants'
+import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
 import { AccountModel } from '../../models/account/account'
 import { isArray } from '../../helpers/custom-validators/misc'
 import { getOrCreateActorAndServerAndModel } from './actor'
 import { logger } from '../../helpers/logger'
 import { VideoPlaylistModel } from '../../models/video/video-playlist'
-import { doRequest, downloadImage } from '../../helpers/requests'
+import { doRequest } from '../../helpers/requests'
 import { checkUrlsSameHost } from '../../helpers/activitypub'
 import * as Bluebird from 'bluebird'
 import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
@@ -16,9 +16,8 @@ import { VideoPlaylistElementModel } from '../../models/video/video-playlist-ele
 import { VideoModel } from '../../models/video/video'
 import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
 import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
-import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
-import { CONFIG } from '../../initializers/config'
 import { sequelizeTypescript } from '../../initializers/database'
+import { createPlaylistThumbnailFromUrl } from '../thumbnail'
 
 function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) {
   const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED
@@ -97,16 +96,20 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc
     return Promise.resolve()
   })
 
-  // Empty playlists generally do not have a miniature, so skip this
-  if (accItems.length !== 0) {
+  const refreshedPlaylist = await VideoPlaylistModel.loadWithAccountAndChannel(playlist.id, null)
+
+  if (playlistObject.icon) {
     try {
-      await generateThumbnailFromUrl(playlist, playlistObject.icon)
+      const thumbnailModel = await createPlaylistThumbnailFromUrl(playlistObject.icon.url, refreshedPlaylist)
+      thumbnailModel.videoPlaylistId = refreshedPlaylist.id
+
+      refreshedPlaylist.setThumbnail(await thumbnailModel.save())
     } catch (err) {
       logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err })
     }
   }
 
-  return resetVideoPlaylistElements(accItems, playlist)
+  return resetVideoPlaylistElements(accItems, refreshedPlaylist)
 }
 
 async function refreshVideoPlaylistIfNeeded (videoPlaylist: VideoPlaylistModel): Promise<VideoPlaylistModel> {
@@ -191,12 +194,6 @@ async function resetVideoPlaylistElements (elementUrls: string[], playlist: Vide
   return undefined
 }
 
-function generateThumbnailFromUrl (playlist: VideoPlaylistModel, icon: ActivityIconObject) {
-  const thumbnailName = playlist.getThumbnailName()
-
-  return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
-}
-
 async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
   const options = {
     uri: playlistUrl,

+ 68 - 24
server/lib/activitypub/videos.ts

@@ -3,11 +3,10 @@ import * as sequelize from 'sequelize'
 import * as magnetUtil from 'magnet-uri'
 import * as request from 'request'
 import {
-  ActivityIconObject,
   ActivityPlaylistSegmentHashesObject,
   ActivityPlaylistUrlObject,
   ActivityUrlObject,
-  ActivityVideoUrlObject,
+  ActivityVideoUrlObject, VideoCreate,
   VideoState
 } from '../../../shared/index'
 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
@@ -16,8 +15,15 @@ import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validat
 import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
 import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
 import { logger } from '../../helpers/logger'
-import { doRequest, downloadImage } from '../../helpers/requests'
-import { ACTIVITY_PUB, MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, REMOTE_SCHEME, THUMBNAILS_SIZE } from '../../initializers/constants'
+import { doRequest } from '../../helpers/requests'
+import {
+  ACTIVITY_PUB,
+  MIMETYPES,
+  P2P_MEDIA_LOADER_PEER_VERSION,
+  PREVIEWS_SIZE,
+  REMOTE_SCHEME,
+  STATIC_PATHS
+} from '../../initializers/constants'
 import { ActorModel } from '../../models/activitypub/actor'
 import { TagModel } from '../../models/video/tag'
 import { VideoModel } from '../../models/video/video'
@@ -43,8 +49,11 @@ import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
 import { AccountVideoRateModel } from '../../models/account/account-video-rate'
 import { VideoShareModel } from '../../models/video/video-share'
 import { VideoCommentModel } from '../../models/video/video-comment'
-import { CONFIG } from '../../initializers/config'
 import { sequelizeTypescript } from '../../initializers/database'
+import { createPlaceholderThumbnail, createVideoThumbnailFromUrl } from '../thumbnail'
+import { ThumbnailModel } from '../../models/video/thumbnail'
+import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
+import { join } from 'path'
 
 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
   // If the video is not private and is published, we federate it
@@ -100,18 +109,18 @@ async function fetchRemoteVideoDescription (video: VideoModel) {
 }
 
 function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
-  const host = video.VideoChannel.Account.Actor.Server.host
+  const url = buildRemoteBaseUrl(video, path)
 
   // We need to provide a callback, if no we could have an uncaught exception
-  return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
+  return request.get(url, err => {
     if (err) reject(err)
   })
 }
 
-function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
-  const thumbnailName = video.getThumbnailName()
+function buildRemoteBaseUrl (video: VideoModel, path: string) {
+  const host = video.VideoChannel.Account.Actor.Server.host
 
-  return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
+  return REMOTE_SCHEME.HTTP + '://' + host + path
 }
 
 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
@@ -236,6 +245,14 @@ async function updateVideoFromAP (options: {
   const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
 
   try {
+    let thumbnailModel: ThumbnailModel
+
+    try {
+      thumbnailModel = await createVideoThumbnailFromUrl(options.videoObject.icon.url, options.video, ThumbnailType.THUMBNAIL)
+    } catch (err) {
+      logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
+    }
+
     await sequelizeTypescript.transaction(async t => {
       const sequelizeOptions = { transaction: t }
 
@@ -272,6 +289,17 @@ async function updateVideoFromAP (options: {
 
       await options.video.save(sequelizeOptions)
 
+      if (thumbnailModel) {
+        thumbnailModel.videoId = options.video.id
+        options.video.addThumbnail(await thumbnailModel.save({ transaction: t }))
+      }
+
+      // FIXME: use icon URL instead
+      const previewUrl = buildRemoteBaseUrl(options.video, join(STATIC_PATHS.PREVIEWS, options.video.getPreview().filename))
+      const previewModel = createPlaceholderThumbnail(previewUrl, options.video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
+
+      options.video.addThumbnail(await previewModel.save({ transaction: t }))
+
       {
         const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
         const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
@@ -347,12 +375,6 @@ async function updateVideoFromAP (options: {
     logger.debug('Cannot update the remote video.', { err })
     throw err
   }
-
-  try {
-    await generateThumbnailFromUrl(options.video, options.videoObject.icon)
-  } catch (err) {
-    logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
-  }
 }
 
 async function refreshVideoIfNeeded (options: {
@@ -412,7 +434,6 @@ export {
   getOrCreateVideoAndAccountAndChannel,
   fetchRemoteVideoStaticFile,
   fetchRemoteVideoDescription,
-  generateThumbnailFromUrl,
   getOrCreateVideoChannelFromVideoObject
 }
 
@@ -440,13 +461,34 @@ function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistS
 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
   logger.debug('Adding remote video %s.', videoObject.id)
 
+  const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
+  const video = VideoModel.build(videoData)
+
+  const promiseThumbnail = createVideoThumbnailFromUrl(videoObject.icon.url, video, ThumbnailType.THUMBNAIL)
+
+  let thumbnailModel: ThumbnailModel
+  if (waitThumbnail === true) {
+    thumbnailModel = await promiseThumbnail
+  }
+
   const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
     const sequelizeOptions = { transaction: t }
 
-    const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
-    const video = VideoModel.build(videoData)
-
     const videoCreated = await video.save(sequelizeOptions)
+    videoCreated.VideoChannel = channelActor.VideoChannel
+
+    if (thumbnailModel) {
+      thumbnailModel.videoId = videoCreated.id
+
+      videoCreated.addThumbnail(await thumbnailModel.save({ transaction: t }))
+    }
+
+    // FIXME: use icon URL instead
+    const previewUrl = buildRemoteBaseUrl(videoCreated, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName()))
+    const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
+    previewModel.videoId = videoCreated.id
+
+    videoCreated.addThumbnail(await previewModel.save({ transaction: t }))
 
     // Process files
     const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
@@ -476,14 +518,16 @@ async function createVideo (videoObject: VideoTorrentObject, channelActor: Actor
 
     logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
 
-    videoCreated.VideoChannel = channelActor.VideoChannel
     return videoCreated
   })
 
-  const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
-    .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
+  if (waitThumbnail === false) {
+    promiseThumbnail.then(thumbnailModel => {
+      thumbnailModel = videoCreated.id
 
-  if (waitThumbnail === true) await p
+      return thumbnailModel.save()
+    })
+  }
 
   return videoCreated
 }

+ 10 - 22
server/lib/files-cache/abstract-video-static-file-cache.ts

@@ -1,41 +1,29 @@
-import * as AsyncLRU from 'async-lru'
 import { createWriteStream, remove } from 'fs-extra'
 import { logger } from '../../helpers/logger'
 import { VideoModel } from '../../models/video/video'
 import { fetchRemoteVideoStaticFile } from '../activitypub'
+import * as memoizee from 'memoizee'
 
 export abstract class AbstractVideoStaticFileCache <T> {
 
-  protected lru
+  getFilePath: (params: T) => Promise<string>
 
-  abstract getFilePath (params: T): Promise<string>
+  abstract getFilePathImpl (params: T): Promise<string>
 
   // Load and save the remote file, then return the local path from filesystem
   protected abstract loadRemoteFile (key: string): Promise<string>
 
   init (max: number, maxAge: number) {
-    this.lru = new AsyncLRU({
-      max,
+    this.getFilePath = memoizee(this.getFilePathImpl, {
       maxAge,
-      load: (key, cb) => {
-        this.loadRemoteFile(key)
-          .then(res => cb(null, res))
-          .catch(err => cb(err))
+      max,
+      promise: true,
+      dispose: (value: string) => {
+        remove(value)
+          .then(() => logger.debug('%s evicted from %s', value, this.constructor.name))
+          .catch(err => logger.error('Cannot remove %s from cache %s.', value, this.constructor.name, { err }))
       }
     })
-
-    this.lru.on('evict', (obj: { key: string, value: string }) => {
-      remove(obj.value)
-        .then(() => logger.debug('%s evicted from %s', obj.value, this.constructor.name))
-    })
-  }
-
-  protected loadFromLRU (key: string) {
-    return new Promise<string>((res, rej) => {
-      this.lru.get(key, (err, value) => {
-        err ? rej(err) : res(value)
-      })
-    })
   }
 
   protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) {

+ 3 - 2
server/lib/files-cache/videos-caption-cache.ts

@@ -20,14 +20,14 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
     return this.instance || (this.instance = new this())
   }
 
-  async getFilePath (params: GetPathParam) {
+  async getFilePathImpl (params: GetPathParam) {
     const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language)
     if (!videoCaption) return undefined
 
     if (videoCaption.isOwned()) return join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName())
 
     const key = params.videoId + VideosCaptionCache.KEY_DELIMITER + params.language
-    return this.loadFromLRU(key)
+    return this.loadRemoteFile(key)
   }
 
   protected async loadRemoteFile (key: string) {
@@ -42,6 +42,7 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
     const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
     if (!video) return undefined
 
+    // FIXME: use URL
     const remoteStaticPath = videoCaption.getCaptionStaticPath()
     const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.getCaptionName())
 

+ 6 - 5
server/lib/files-cache/videos-preview-cache.ts

@@ -16,13 +16,13 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
     return this.instance || (this.instance = new this())
   }
 
-  async getFilePath (videoUUID: string) {
+  async getFilePathImpl (videoUUID: string) {
     const video = await VideoModel.loadByUUIDWithFile(videoUUID)
     if (!video) return undefined
 
-    if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
+    if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreview().filename)
 
-    return this.loadFromLRU(videoUUID)
+    return this.loadRemoteFile(videoUUID)
   }
 
   protected async loadRemoteFile (key: string) {
@@ -31,8 +31,9 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
 
     if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
 
-    const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
-    const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, video.getPreviewName())
+    // FIXME: use URL
+    const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreview().filename)
+    const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, video.getPreview().filename)
 
     return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
   }

+ 23 - 18
server/lib/job-queue/handlers/video-import.ts

@@ -6,8 +6,7 @@ import { VideoImportState } from '../../../../shared/models/videos'
 import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
 import { extname, join } from 'path'
 import { VideoFileModel } from '../../../models/video/video-file'
-import { PREVIEWS_SIZE, THUMBNAILS_SIZE, VIDEO_IMPORT_TIMEOUT } from '../../../initializers/constants'
-import { downloadImage } from '../../../helpers/requests'
+import { VIDEO_IMPORT_TIMEOUT } from '../../../initializers/constants'
 import { VideoState } from '../../../../shared'
 import { JobQueue } from '../index'
 import { federateVideoIfNeeded } from '../../activitypub'
@@ -18,6 +17,9 @@ import { move, remove, stat } from 'fs-extra'
 import { Notifier } from '../../notifier'
 import { CONFIG } from '../../../initializers/config'
 import { sequelizeTypescript } from '../../../initializers/database'
+import { ThumbnailModel } from '../../../models/video/thumbnail'
+import { createVideoThumbnailFromUrl, generateVideoThumbnail } from '../../thumbnail'
+import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
 
 type VideoImportYoutubeDLPayload = {
   type: 'youtube-dl'
@@ -146,25 +148,19 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
     tempVideoPath = null // This path is not used anymore
 
     // Process thumbnail
-    if (options.downloadThumbnail) {
-      if (options.thumbnailUrl) {
-        await downloadImage(options.thumbnailUrl, CONFIG.STORAGE.THUMBNAILS_DIR, videoImport.Video.getThumbnailName(), THUMBNAILS_SIZE)
-      } else {
-        await videoImport.Video.createThumbnail(videoFile)
-      }
-    } else if (options.generateThumbnail) {
-      await videoImport.Video.createThumbnail(videoFile)
+    let thumbnailModel: ThumbnailModel
+    if (options.downloadThumbnail && options.thumbnailUrl) {
+      thumbnailModel = await createVideoThumbnailFromUrl(options.thumbnailUrl, videoImport.Video, ThumbnailType.THUMBNAIL)
+    } else if (options.generateThumbnail || options.downloadThumbnail) {
+      thumbnailModel = await generateVideoThumbnail(videoImport.Video, videoFile, ThumbnailType.THUMBNAIL)
     }
 
     // Process preview
-    if (options.downloadPreview) {
-      if (options.thumbnailUrl) {
-        await downloadImage(options.thumbnailUrl, CONFIG.STORAGE.PREVIEWS_DIR, videoImport.Video.getPreviewName(), PREVIEWS_SIZE)
-      } else {
-        await videoImport.Video.createPreview(videoFile)
-      }
-    } else if (options.generatePreview) {
-      await videoImport.Video.createPreview(videoFile)
+    let previewModel: ThumbnailModel
+    if (options.downloadPreview && options.thumbnailUrl) {
+      previewModel = await createVideoThumbnailFromUrl(options.thumbnailUrl, videoImport.Video, ThumbnailType.PREVIEW)
+    } else if (options.generatePreview || options.downloadPreview) {
+      previewModel = await generateVideoThumbnail(videoImport.Video, videoFile, ThumbnailType.PREVIEW)
     }
 
     // Create torrent
@@ -184,6 +180,15 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
       video.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED
       await video.save({ transaction: t })
 
+      if (thumbnailModel) {
+        thumbnailModel.videoId = video.id
+        video.addThumbnail(await thumbnailModel.save({ transaction: t }))
+      }
+      if (previewModel) {
+        previewModel.videoId = video.id
+        video.addThumbnail(await previewModel.save({ transaction: t }))
+      }
+
       // Now we can federate the video (reload from database, we need more attributes)
       const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
       await federateVideoIfNeeded(videoForFederation, true, t)

+ 151 - 0
server/lib/thumbnail.ts

@@ -0,0 +1,151 @@
+import { VideoFileModel } from '../models/video/video-file'
+import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils'
+import { CONFIG } from '../initializers/config'
+import { PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
+import { VideoModel } from '../models/video/video'
+import { ThumbnailModel } from '../models/video/thumbnail'
+import { ThumbnailType } from '../../shared/models/videos/thumbnail.type'
+import { processImage } from '../helpers/image-utils'
+import { join } from 'path'
+import { downloadImage } from '../helpers/requests'
+import { VideoPlaylistModel } from '../models/video/video-playlist'
+
+type ImageSize = { height: number, width: number }
+
+function createPlaylistThumbnailFromExisting (inputPath: string, playlist: VideoPlaylistModel, keepOriginal = false, size?: ImageSize) {
+  const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size)
+  const type = ThumbnailType.THUMBNAIL
+
+  const thumbnailCreator = () => processImage({ path: inputPath }, outputPath, { width, height }, keepOriginal)
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
+}
+
+function createPlaylistThumbnailFromUrl (url: string, playlist: VideoPlaylistModel, size?: ImageSize) {
+  const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size)
+  const type = ThumbnailType.THUMBNAIL
+
+  const thumbnailCreator = () => downloadImage(url, basePath, filename, { width, height })
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, url })
+}
+
+function createVideoThumbnailFromUrl (url: string, video: VideoModel, type: ThumbnailType, size?: ImageSize) {
+  const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
+  const thumbnailCreator = () => downloadImage(url, basePath, filename, { width, height })
+
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, url })
+}
+
+function createVideoThumbnailFromExisting (inputPath: string, video: VideoModel, type: ThumbnailType, size?: ImageSize) {
+  const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
+  const thumbnailCreator = () => processImage({ path: inputPath }, outputPath, { width, height })
+
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
+}
+
+function generateVideoThumbnail (video: VideoModel, videoFile: VideoFileModel, type: ThumbnailType) {
+  const input = video.getVideoFilePath(videoFile)
+
+  const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type)
+  const thumbnailCreator = () => generateImageFromVideoFile(input, basePath, filename, { height, width })
+
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
+}
+
+function createPlaceholderThumbnail (url: string, video: VideoModel, type: ThumbnailType, size: ImageSize) {
+  const { filename, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
+
+  const thumbnail = existingThumbnail ? existingThumbnail : new ThumbnailModel()
+
+  thumbnail.filename = filename
+  thumbnail.height = height
+  thumbnail.width = width
+  thumbnail.type = type
+  thumbnail.url = url
+
+  return thumbnail
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  generateVideoThumbnail,
+  createVideoThumbnailFromUrl,
+  createVideoThumbnailFromExisting,
+  createPlaceholderThumbnail,
+  createPlaylistThumbnailFromUrl,
+  createPlaylistThumbnailFromExisting
+}
+
+function buildMetadataFromPlaylist (playlist: VideoPlaylistModel, size: ImageSize) {
+  const filename = playlist.generateThumbnailName()
+  const basePath = CONFIG.STORAGE.THUMBNAILS_DIR
+
+  return {
+    filename,
+    basePath,
+    existingThumbnail: playlist.Thumbnail,
+    outputPath: join(basePath, filename),
+    height: size ? size.height : THUMBNAILS_SIZE.height,
+    width: size ? size.width : THUMBNAILS_SIZE.width
+  }
+}
+
+function buildMetadataFromVideo (video: VideoModel, type: ThumbnailType, size?: ImageSize) {
+  const existingThumbnail = Array.isArray(video.Thumbnails)
+    ? video.Thumbnails.find(t => t.type === type)
+    : undefined
+
+  if (type === ThumbnailType.THUMBNAIL) {
+    const filename = video.generateThumbnailName()
+    const basePath = CONFIG.STORAGE.THUMBNAILS_DIR
+
+    return {
+      filename,
+      basePath,
+      existingThumbnail,
+      outputPath: join(basePath, filename),
+      height: size ? size.height : THUMBNAILS_SIZE.height,
+      width: size ? size.width : THUMBNAILS_SIZE.width
+    }
+  }
+
+  if (type === ThumbnailType.PREVIEW) {
+    const filename = video.generatePreviewName()
+    const basePath = CONFIG.STORAGE.PREVIEWS_DIR
+
+    return {
+      filename,
+      basePath,
+      existingThumbnail,
+      outputPath: join(basePath, filename),
+      height: size ? size.height : PREVIEWS_SIZE.height,
+      width: size ? size.width : PREVIEWS_SIZE.width
+    }
+  }
+
+  return undefined
+}
+
+async function createThumbnailFromFunction (parameters: {
+  thumbnailCreator: () => Promise<any>,
+  filename: string,
+  height: number,
+  width: number,
+  type: ThumbnailType,
+  url?: string,
+  existingThumbnail?: ThumbnailModel
+}) {
+  const { thumbnailCreator, filename, width, height, type, existingThumbnail, url = null } = parameters
+
+  const thumbnail = existingThumbnail ? existingThumbnail : new ThumbnailModel()
+
+  thumbnail.filename = filename
+  thumbnail.height = height
+  thumbnail.width = width
+  thumbnail.type = type
+  thumbnail.url = url
+
+  await thumbnailCreator()
+
+  return thumbnail
+}

+ 116 - 0
server/models/video/thumbnail.ts

@@ -0,0 +1,116 @@
+import { join } from 'path'
+import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
+import { logger } from '../../helpers/logger'
+import { remove } from 'fs-extra'
+import { CONFIG } from '../../initializers/config'
+import { VideoModel } from './video'
+import { VideoPlaylistModel } from './video-playlist'
+import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
+
+@Table({
+  tableName: 'thumbnail',
+  indexes: [
+    {
+      fields: [ 'videoId' ]
+    },
+    {
+      fields: [ 'videoPlaylistId' ],
+      unique: true
+    }
+  ]
+})
+export class ThumbnailModel extends Model<ThumbnailModel> {
+
+  @AllowNull(false)
+  @Column
+  filename: string
+
+  @AllowNull(true)
+  @Default(null)
+  @Column
+  height: number
+
+  @AllowNull(true)
+  @Default(null)
+  @Column
+  width: number
+
+  @AllowNull(false)
+  @Column
+  type: ThumbnailType
+
+  @AllowNull(true)
+  @Column
+  url: string
+
+  @ForeignKey(() => VideoModel)
+  @Column
+  videoId: number
+
+  @BelongsTo(() => VideoModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'CASCADE'
+  })
+  Video: VideoModel
+
+  @ForeignKey(() => VideoPlaylistModel)
+  @Column
+  videoPlaylistId: number
+
+  @BelongsTo(() => VideoPlaylistModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'CASCADE'
+  })
+  VideoPlaylist: VideoPlaylistModel
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  private static types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = {
+    [ThumbnailType.THUMBNAIL]: {
+      label: 'thumbnail',
+      directory: CONFIG.STORAGE.THUMBNAILS_DIR,
+      staticPath: STATIC_PATHS.THUMBNAILS
+    },
+    [ThumbnailType.PREVIEW]: {
+      label: 'preview',
+      directory: CONFIG.STORAGE.PREVIEWS_DIR,
+      staticPath: STATIC_PATHS.PREVIEWS
+    }
+  }
+
+  @AfterDestroy
+  static removeFilesAndSendDelete (instance: ThumbnailModel) {
+    logger.info('Removing %s file %s.', ThumbnailModel.types[instance.type].label, instance.filename)
+
+    // Don't block the transaction
+    instance.removeThumbnail()
+            .catch(err => logger.error('Cannot remove thumbnail file %s.', instance.filename, err))
+  }
+
+  static generateDefaultPreviewName (videoUUID: string) {
+    return videoUUID + '.jpg'
+  }
+
+  getUrl () {
+    if (this.url) return this.url
+
+    const staticPath = ThumbnailModel.types[this.type].staticPath
+    return WEBSERVER.URL + staticPath + this.filename
+  }
+
+  removeThumbnail () {
+    const directory = ThumbnailModel.types[this.type].directory
+    const thumbnailPath = join(directory, this.filename)
+
+    return remove(thumbnailPath)
+  }
+}

+ 4 - 4
server/models/video/video-format-utils.ts

@@ -7,7 +7,7 @@ import {
   ActivityUrlObject,
   VideoTorrentObject
 } from '../../../shared/models/activitypub/objects'
-import { MIMETYPES, THUMBNAILS_SIZE, WEBSERVER } from '../../initializers/constants'
+import { MIMETYPES, WEBSERVER } from '../../initializers/constants'
 import { VideoCaptionModel } from './video-caption'
 import {
   getVideoCommentsActivityPubUrl,
@@ -326,10 +326,10 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
     subtitleLanguage,
     icon: {
       type: 'Image',
-      url: video.getThumbnailUrl(baseUrlHttp),
+      url: video.getThumbnail().getUrl(),
       mediaType: 'image/jpeg',
-      width: THUMBNAILS_SIZE.width,
-      height: THUMBNAILS_SIZE.height
+      width: video.getThumbnail().width,
+      height: video.getThumbnail().height
     },
     url,
     likes: getVideoLikesActivityPubUrl(video),

+ 56 - 29
server/models/video/video-playlist.ts

@@ -1,6 +1,5 @@
 import {
   AllowNull,
-  BeforeDestroy,
   BelongsTo,
   Column,
   CreatedAt,
@@ -8,6 +7,7 @@ import {
   Default,
   ForeignKey,
   HasMany,
+  HasOne,
   Is,
   IsUUID,
   Model,
@@ -40,16 +40,16 @@ import { join } from 'path'
 import { VideoPlaylistElementModel } from './video-playlist-element'
 import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
 import { activityPubCollectionPagination } from '../../helpers/activitypub'
-import { remove } from 'fs-extra'
-import { logger } from '../../helpers/logger'
 import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
-import { CONFIG } from '../../initializers/config'
+import { ThumbnailModel } from './thumbnail'
+import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
 
 enum ScopeNames {
   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
   WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
   WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
   WITH_ACCOUNT = 'WITH_ACCOUNT',
+  WITH_THUMBNAIL = 'WITH_THUMBNAIL',
   WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
 }
 
@@ -62,6 +62,14 @@ type AvailableForListOptions = {
 }
 
 @Scopes({
+  [ ScopeNames.WITH_THUMBNAIL ]: {
+    include: [
+      {
+        model: () => ThumbnailModel,
+        required: false
+      }
+    ]
+  },
   [ ScopeNames.WITH_VIDEOS_LENGTH ]: {
     attributes: {
       include: [
@@ -256,12 +264,15 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
   })
   VideoPlaylistElements: VideoPlaylistElementModel[]
 
-  @BeforeDestroy
-  static async removeFiles (instance: VideoPlaylistModel) {
-    logger.info('Removing files of video playlist %s.', instance.url)
-
-    return instance.removeThumbnail()
-  }
+  @HasOne(() => ThumbnailModel, {
+    foreignKey: {
+      name: 'videoPlaylistId',
+      allowNull: true
+    },
+    onDelete: 'CASCADE',
+    hooks: true
+  })
+  Thumbnail: ThumbnailModel
 
   static listForApi (options: {
     followerActorId: number
@@ -292,7 +303,8 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
           } as AvailableForListOptions
         ]
       } as any, // FIXME: typings
-      ScopeNames.WITH_VIDEOS_LENGTH
+      ScopeNames.WITH_VIDEOS_LENGTH,
+      ScopeNames.WITH_THUMBNAIL
     ]
 
     return VideoPlaylistModel
@@ -365,7 +377,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
     }
 
     return VideoPlaylistModel
-      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH ])
+      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
       .findOne(query)
   }
 
@@ -378,7 +390,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
     }
 
     return VideoPlaylistModel
-      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ])
+      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
       .findOne(query)
   }
 
@@ -389,7 +401,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       }
     }
 
-    return VideoPlaylistModel.scope(ScopeNames.WITH_ACCOUNT).findOne(query)
+    return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
   }
 
   static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
@@ -411,24 +423,34 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
     return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
   }
 
-  getThumbnailName () {
+  setThumbnail (thumbnail: ThumbnailModel) {
+    this.Thumbnail = thumbnail
+  }
+
+  getThumbnail () {
+    return this.Thumbnail
+  }
+
+  hasThumbnail () {
+    return !!this.Thumbnail
+  }
+
+  generateThumbnailName () {
     const extension = '.jpg'
 
     return 'playlist-' + this.uuid + extension
   }
 
   getThumbnailUrl () {
-    return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
+    if (!this.hasThumbnail()) return null
+
+    return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnail().filename
   }
 
   getThumbnailStaticPath () {
-    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
-  }
+    if (!this.hasThumbnail()) return null
 
-  removeThumbnail () {
-    const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
-    return remove(thumbnailPath)
-      .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
+    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnail().filename)
   }
 
   setAsRefreshed () {
@@ -482,6 +504,17 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
     }
 
+    let icon: ActivityIconObject
+    if (this.hasThumbnail()) {
+      icon = {
+        type: 'Image' as 'Image',
+        url: this.getThumbnailUrl(),
+        mediaType: 'image/jpeg' as 'image/jpeg',
+        width: THUMBNAILS_SIZE.width,
+        height: THUMBNAILS_SIZE.height
+      }
+    }
+
     return activityPubCollectionPagination(this.url, handler, page)
       .then(o => {
         return Object.assign(o, {
@@ -492,13 +525,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
           published: this.createdAt.toISOString(),
           updated: this.updatedAt.toISOString(),
           attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
-          icon: {
-            type: 'Image' as 'Image',
-            url: this.getThumbnailUrl(),
-            mediaType: 'image/jpeg' as 'image/jpeg',
-            width: THUMBNAILS_SIZE.width,
-            height: THUMBNAILS_SIZE.height
-          }
+          icon
         })
       })
   }

+ 90 - 65
server/models/video/video.ts

@@ -107,6 +107,8 @@ import { VideoImportModel } from './video-import'
 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
 import { VideoPlaylistElementModel } from './video-playlist-element'
 import { CONFIG } from '../../initializers/config'
+import { ThumbnailModel } from './thumbnail'
+import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
 
 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
 const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -181,7 +183,8 @@ export enum ScopeNames {
   WITH_BLACKLISTED = 'WITH_BLACKLISTED',
   WITH_USER_HISTORY = 'WITH_USER_HISTORY',
   WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
-  WITH_USER_ID = 'WITH_USER_ID'
+  WITH_USER_ID = 'WITH_USER_ID',
+  WITH_THUMBNAILS = 'WITH_THUMBNAILS'
 }
 
 type ForAPIOptions = {
@@ -473,6 +476,14 @@ type AvailableForListIDsOptions = {
 
     return query
   },
+  [ ScopeNames.WITH_THUMBNAILS ]: {
+    include: [
+      {
+        model: () => ThumbnailModel,
+        required: false
+      }
+    ]
+  },
   [ ScopeNames.WITH_USER_ID ]: {
     include: [
       {
@@ -771,6 +782,16 @@ export class VideoModel extends Model<VideoModel> {
   })
   Tags: TagModel[]
 
+  @HasMany(() => ThumbnailModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: true
+    },
+    hooks: true,
+    onDelete: 'cascade'
+  })
+  Thumbnails: ThumbnailModel[]
+
   @HasMany(() => VideoPlaylistElementModel, {
     foreignKey: {
       name: 'videoId',
@@ -920,15 +941,11 @@ export class VideoModel extends Model<VideoModel> {
 
     logger.info('Removing files of video %s.', instance.url)
 
-    tasks.push(instance.removeThumbnail())
-
     if (instance.isOwned()) {
       if (!Array.isArray(instance.VideoFiles)) {
         instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
       }
 
-      tasks.push(instance.removePreview())
-
       // Remove physical files and torrents
       instance.VideoFiles.forEach(file => {
         tasks.push(instance.removeFile(file))
@@ -955,7 +972,11 @@ export class VideoModel extends Model<VideoModel> {
       }
     }
 
-    return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]).findAll(query)
+    return VideoModel.scope([
+      ScopeNames.WITH_FILES,
+      ScopeNames.WITH_STREAMING_PLAYLISTS,
+      ScopeNames.WITH_THUMBNAILS
+    ]).findAll(query)
   }
 
   static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
@@ -1048,7 +1069,7 @@ export class VideoModel extends Model<VideoModel> {
 
     return Bluebird.all([
       // FIXME: typing issue
-      VideoModel.findAll(query as any),
+      VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query as any),
       VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
     ]).then(([ rows, totals ]) => {
       // totals: totalVideos + totalVideoShares
@@ -1102,12 +1123,14 @@ export class VideoModel extends Model<VideoModel> {
       })
     }
 
-    return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
-      return {
-        data: rows,
-        total: count
-      }
-    })
+    return VideoModel.scope(ScopeNames.WITH_THUMBNAILS)
+                     .findAndCountAll(query)
+                     .then(({ rows, count }) => {
+                       return {
+                         data: rows,
+                         total: count
+                       }
+                     })
   }
 
   static async listForApi (options: {
@@ -1296,7 +1319,7 @@ export class VideoModel extends Model<VideoModel> {
       transaction: t
     }
 
-    return VideoModel.findOne(options)
+    return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
   }
 
   static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
@@ -1306,7 +1329,11 @@ export class VideoModel extends Model<VideoModel> {
       transaction: t
     }
 
-    return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_USER_ID ]).findOne(options)
+    return VideoModel.scope([
+      ScopeNames.WITH_BLACKLISTED,
+      ScopeNames.WITH_USER_ID,
+      ScopeNames.WITH_THUMBNAILS
+    ]).findOne(options)
   }
 
   static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
@@ -1318,12 +1345,15 @@ export class VideoModel extends Model<VideoModel> {
       transaction: t
     }
 
-    return VideoModel.findOne(options)
+    return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
   }
 
   static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) {
-    return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ])
-                     .findByPk(id, { transaction: t, logging })
+    return VideoModel.scope([
+      ScopeNames.WITH_FILES,
+      ScopeNames.WITH_STREAMING_PLAYLISTS,
+      ScopeNames.WITH_THUMBNAILS
+    ]).findByPk(id, { transaction: t, logging })
   }
 
   static loadByUUIDWithFile (uuid: string) {
@@ -1333,7 +1363,7 @@ export class VideoModel extends Model<VideoModel> {
       }
     }
 
-    return VideoModel.findOne(options)
+    return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
   }
 
   static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
@@ -1344,7 +1374,7 @@ export class VideoModel extends Model<VideoModel> {
       transaction
     }
 
-    return VideoModel.findOne(query)
+    return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
   }
 
   static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) {
@@ -1358,7 +1388,8 @@ export class VideoModel extends Model<VideoModel> {
     return VideoModel.scope([
       ScopeNames.WITH_ACCOUNT_DETAILS,
       ScopeNames.WITH_FILES,
-      ScopeNames.WITH_STREAMING_PLAYLISTS
+      ScopeNames.WITH_STREAMING_PLAYLISTS,
+      ScopeNames.WITH_THUMBNAILS
     ]).findOne(query)
   }
 
@@ -1377,7 +1408,8 @@ export class VideoModel extends Model<VideoModel> {
       ScopeNames.WITH_ACCOUNT_DETAILS,
       ScopeNames.WITH_SCHEDULED_UPDATE,
       ScopeNames.WITH_FILES,
-      ScopeNames.WITH_STREAMING_PLAYLISTS
+      ScopeNames.WITH_STREAMING_PLAYLISTS,
+      ScopeNames.WITH_THUMBNAILS
     ]
 
     if (userId) {
@@ -1403,6 +1435,7 @@ export class VideoModel extends Model<VideoModel> {
       ScopeNames.WITH_BLACKLISTED,
       ScopeNames.WITH_ACCOUNT_DETAILS,
       ScopeNames.WITH_SCHEDULED_UPDATE,
+      ScopeNames.WITH_THUMBNAILS,
       { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings
       { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings
     ]
@@ -1555,7 +1588,7 @@ export class VideoModel extends Model<VideoModel> {
     }
 
     // FIXME: typing
-    const apiScope: any[] = []
+    const apiScope: any[] = [ ScopeNames.WITH_THUMBNAILS ]
 
     if (options.user) {
       apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
@@ -1611,18 +1644,37 @@ export class VideoModel extends Model<VideoModel> {
     return maxBy(this.VideoFiles, file => file.resolution)
   }
 
+  addThumbnail (thumbnail: ThumbnailModel) {
+    if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
+
+    // Already have this thumbnail, skip
+    if (this.Thumbnails.find(t => t.id === thumbnail.id)) return
+
+    this.Thumbnails.push(thumbnail)
+  }
+
   getVideoFilename (videoFile: VideoFileModel) {
     return this.uuid + '-' + videoFile.resolution + videoFile.extname
   }
 
-  getThumbnailName () {
-    const extension = '.jpg'
-    return this.uuid + extension
+  generateThumbnailName () {
+    return this.uuid + '.jpg'
   }
 
-  getPreviewName () {
-    const extension = '.jpg'
-    return this.uuid + extension
+  getThumbnail () {
+    if (Array.isArray(this.Thumbnails) === false) return undefined
+
+    return this.Thumbnails.find(t => t.type === ThumbnailType.THUMBNAIL)
+  }
+
+  generatePreviewName () {
+    return this.uuid + '.jpg'
+  }
+
+  getPreview () {
+    if (Array.isArray(this.Thumbnails) === false) return undefined
+
+    return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
   }
 
   getTorrentFileName (videoFile: VideoFileModel) {
@@ -1634,24 +1686,6 @@ export class VideoModel extends Model<VideoModel> {
     return this.remote === false
   }
 
-  createPreview (videoFile: VideoFileModel) {
-    return generateImageFromVideoFile(
-      this.getVideoFilePath(videoFile),
-      CONFIG.STORAGE.PREVIEWS_DIR,
-      this.getPreviewName(),
-      PREVIEWS_SIZE
-    )
-  }
-
-  createThumbnail (videoFile: VideoFileModel) {
-    return generateImageFromVideoFile(
-      this.getVideoFilePath(videoFile),
-      CONFIG.STORAGE.THUMBNAILS_DIR,
-      this.getThumbnailName(),
-      THUMBNAILS_SIZE
-    )
-  }
-
   getTorrentFilePath (videoFile: VideoFileModel) {
     return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
   }
@@ -1692,11 +1726,18 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   getThumbnailStaticPath () {
-    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
+    const thumbnail = this.getThumbnail()
+    if (!thumbnail) return null
+
+    return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
   }
 
   getPreviewStaticPath () {
-    return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
+    const preview = this.getPreview()
+    if (!preview) return null
+
+    // We use a local cache, so specify our cache endpoint instead of potential remote URL
+    return join(STATIC_PATHS.PREVIEWS, preview.filename)
   }
 
   toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
@@ -1732,18 +1773,6 @@ export class VideoModel extends Model<VideoModel> {
     return `/api/${API_VERSION}/videos/${this.uuid}/description`
   }
 
-  removeThumbnail () {
-    const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
-    return remove(thumbnailPath)
-      .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
-  }
-
-  removePreview () {
-    const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
-    return remove(previewPath)
-      .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
-  }
-
   removeFile (videoFile: VideoFileModel, isRedundancy = false) {
     const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
 
@@ -1816,10 +1845,6 @@ export class VideoModel extends Model<VideoModel> {
     return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
   }
 
-  getThumbnailUrl (baseUrlHttp: string) {
-    return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
-  }
-
   getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
   }

+ 1 - 1
shared/models/activitypub/objects/playlist-object.ts

@@ -11,7 +11,7 @@ export interface PlaylistObject {
   totalItems: number
   attributedTo: string[]
 
-  icon: ActivityIconObject
+  icon?: ActivityIconObject
 
   published: string
   updated: string

+ 4 - 0
shared/models/videos/thumbnail.type.ts

@@ -0,0 +1,4 @@
+export enum ThumbnailType {
+  THUMBNAIL = 1,
+  PREVIEW = 2
+}