From e8bafea35bc930cb8ac5b2d521a188642a1adffe Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 17 Apr 2019 10:07:00 +0200 Subject: [PATCH] Create a dedicated table to track video thumbnails --- server.ts | 2 - server/controllers/api/video-playlist.ts | 47 +++--- server/controllers/api/videos/import.ts | 70 +++++--- server/controllers/api/videos/index.ts | 62 +++---- server/controllers/api/videos/ownership.ts | 1 - server/controllers/static.ts | 2 +- server/helpers/image-utils.ts | 5 +- server/initializers/database.ts | 4 +- server/lib/activitypub/playlist.ts | 25 ++- server/lib/activitypub/videos.ts | 92 ++++++++--- .../abstract-video-static-file-cache.ts | 32 ++-- .../lib/files-cache/videos-caption-cache.ts | 5 +- .../lib/files-cache/videos-preview-cache.ts | 11 +- server/lib/job-queue/handlers/video-import.ts | 41 +++-- server/lib/thumbnail.ts | 151 +++++++++++++++++ server/models/video/thumbnail.ts | 116 +++++++++++++ server/models/video/video-format-utils.ts | 8 +- server/models/video/video-playlist.ts | 85 ++++++---- server/models/video/video.ts | 155 ++++++++++-------- .../activitypub/objects/playlist-object.ts | 2 +- shared/models/videos/thumbnail.type.ts | 4 + 21 files changed, 654 insertions(+), 266 deletions(-) create mode 100644 server/lib/thumbnail.ts create mode 100644 server/models/video/thumbnail.ts create mode 100644 shared/models/videos/thumbnail.type.ts diff --git a/server.ts b/server.ts index 3884dd13c..aa4382ee7 100644 --- a/server.ts +++ b/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) }) diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts index 71c244a60..99325aa9d 100644 --- a/server/controllers/api/video-playlist.ts +++ b/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) diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts index a72b8c72e..f9a24a0c2 100644 --- a/server/controllers/api/videos/import.ts +++ b/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 -): Bluebird { +}): Bluebird { + 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 diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index d6f513254..24721a17f 100644 --- a/server/controllers/api/videos/index.ts +++ b/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) diff --git a/server/controllers/api/videos/ownership.ts b/server/controllers/api/videos/ownership.ts index fc73856c9..bc247c4ee 100644 --- a/server/controllers/api/videos/ownership.ts +++ b/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() diff --git a/server/controllers/static.ts b/server/controllers/static.ts index f6bb88725..d75b95f52 100644 --- a/server/controllers/static.ts +++ b/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) diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts index e43ea3f1d..eeaef0f5d 100644 --- a/server/helpers/image-utils.ts +++ b/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) } // --------------------------------------------------------------------------- diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 872a56220..8f237eb23 100644 --- a/server/initializers/database.ts +++ b/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 diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts index f312409bc..341e469f3 100644 --- a/server/lib/activitypub/playlist.ts +++ b/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 { @@ -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, diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index b9252e363..16c37a55f 100644 --- a/server/lib/activitypub/videos.ts +++ b/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 } diff --git a/server/lib/files-cache/abstract-video-static-file-cache.ts b/server/lib/files-cache/abstract-video-static-file-cache.ts index 7512f2b9d..61837e0f8 100644 --- a/server/lib/files-cache/abstract-video-static-file-cache.ts +++ b/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 { - protected lru + getFilePath: (params: T) => Promise - abstract getFilePath (params: T): Promise + abstract getFilePathImpl (params: T): Promise // Load and save the remote file, then return the local path from filesystem protected abstract loadRemoteFile (key: string): Promise 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((res, rej) => { - this.lru.get(key, (err, value) => { - err ? rej(err) : res(value) - }) - }) } protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) { diff --git a/server/lib/files-cache/videos-caption-cache.ts b/server/lib/files-cache/videos-caption-cache.ts index 0926f4009..d4a0a3345 100644 --- a/server/lib/files-cache/videos-caption-cache.ts +++ b/server/lib/files-cache/videos-caption-cache.ts @@ -20,14 +20,14 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache { 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 { 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()) diff --git a/server/lib/files-cache/videos-preview-cache.ts b/server/lib/files-cache/videos-preview-cache.ts index 6575e1c83..fc0d92c78 100644 --- a/server/lib/files-cache/videos-preview-cache.ts +++ b/server/lib/files-cache/videos-preview-cache.ts @@ -16,13 +16,13 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache { 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 { 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) } diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index 8e8aa1597..3fa0dd65d 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/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, 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, 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) diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts new file mode 100644 index 000000000..344c28566 --- /dev/null +++ b/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, + 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 +} diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts new file mode 100644 index 000000000..baa5533ac --- /dev/null +++ b/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 { + + @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) + } +} diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index 64771b1ff..89992a5a8 100644 --- a/server/models/video/video-format-utils.ts +++ b/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), diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index 0725b752a..073609c24 100644 --- a/server/models/video/video-playlist.ts +++ b/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 { }) 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 { } 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 { } 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 { } 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 { } } - 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 { 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 { 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 { 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 }) }) } diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 38447797e..9840d17fd 100644 --- a/server/models/video/video.ts +++ b/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 { }) 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 { 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 { } } - 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 { 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 { }) } - 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 { 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 { 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 { 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 { } } - 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 { 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 { 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 { 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 { 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 { } // 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 { 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 { 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 { } 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 { 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 { 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) } diff --git a/shared/models/activitypub/objects/playlist-object.ts b/shared/models/activitypub/objects/playlist-object.ts index c11a23a69..b561d8efd 100644 --- a/shared/models/activitypub/objects/playlist-object.ts +++ b/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 diff --git a/shared/models/videos/thumbnail.type.ts b/shared/models/videos/thumbnail.type.ts new file mode 100644 index 000000000..317b4db43 --- /dev/null +++ b/shared/models/videos/thumbnail.type.ts @@ -0,0 +1,4 @@ +export enum ThumbnailType { + THUMBNAIL = 1, + PREVIEW = 2 +} -- 2.41.0