From a8b1b40485145ac1eae513a661d7dd6e0986ce96 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 12 Feb 2021 16:23:19 +0100 Subject: [PATCH] Generate a name for thumbnails Allows aggressive caching --- scripts/prune-storage.ts | 9 +++--- server/controllers/lazy-static.ts | 4 +-- server/controllers/static.ts | 29 +++++++++-------- server/initializers/constants.ts | 2 +- .../migrations/0575-duplicate-thumbnail.ts | 24 ++++++++++++++ server/lib/activitypub/videos.ts | 18 ++++++----- .../lib/files-cache/videos-preview-cache.ts | 15 ++++++--- server/lib/thumbnail.ts | 18 ++++++++--- server/models/video/thumbnail.ts | 31 ++++++++++++------- server/models/video/video.ts | 5 +-- server/types/models/video/thumbnail.ts | 12 +++++++ 11 files changed, 114 insertions(+), 53 deletions(-) create mode 100644 server/initializers/migrations/0575-duplicate-thumbnail.ts diff --git a/scripts/prune-storage.ts b/scripts/prune-storage.ts index 1def1d792..788d97997 100755 --- a/scripts/prune-storage.ts +++ b/scripts/prune-storage.ts @@ -13,6 +13,7 @@ import { getUUIDFromFilename } from '../server/helpers/utils' import { ThumbnailModel } from '../server/models/video/thumbnail' import { AvatarModel } from '../server/models/avatar/avatar' import { uniq, values } from 'lodash' +import { ThumbnailType } from '@shared/models' run() .then(() => process.exit(0)) @@ -39,8 +40,8 @@ async function run () { await pruneDirectory(CONFIG.STORAGE.REDUNDANCY_DIR, doesRedundancyExist), - await pruneDirectory(CONFIG.STORAGE.PREVIEWS_DIR, doesThumbnailExist(true)), - await pruneDirectory(CONFIG.STORAGE.THUMBNAILS_DIR, doesThumbnailExist(false)), + await pruneDirectory(CONFIG.STORAGE.PREVIEWS_DIR, doesThumbnailExist(true, ThumbnailType.PREVIEW)), + await pruneDirectory(CONFIG.STORAGE.THUMBNAILS_DIR, doesThumbnailExist(false, ThumbnailType.MINIATURE)), await pruneDirectory(CONFIG.STORAGE.AVATARS_DIR, doesAvatarExist) ) @@ -92,9 +93,9 @@ function doesVideoExist (keepOnlyOwned: boolean) { } } -function doesThumbnailExist (keepOnlyOwned: boolean) { +function doesThumbnailExist (keepOnlyOwned: boolean, type: ThumbnailType) { return async (file: string) => { - const thumbnail = await ThumbnailModel.loadByName(file) + const thumbnail = await ThumbnailModel.loadWithVideoByName(file, type) if (!thumbnail) return false if (keepOnlyOwned) { diff --git a/server/controllers/lazy-static.ts b/server/controllers/lazy-static.ts index 5c6369c9e..847d24fd4 100644 --- a/server/controllers/lazy-static.ts +++ b/server/controllers/lazy-static.ts @@ -18,7 +18,7 @@ lazyStaticRouter.use( ) lazyStaticRouter.use( - LAZY_STATIC_PATHS.PREVIEWS + ':uuid.jpg', + LAZY_STATIC_PATHS.PREVIEWS + ':filename', asyncMiddleware(getPreview) ) @@ -71,7 +71,7 @@ async function getAvatar (req: express.Request, res: express.Response) { } async function getPreview (req: express.Request, res: express.Response) { - const result = await VideosPreviewCache.Instance.getFilePath(req.params.uuid) + const result = await VideosPreviewCache.Instance.getFilePath(req.params.filename) if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.SERVER }) diff --git a/server/controllers/static.ts b/server/controllers/static.ts index a645c496b..2064857eb 100644 --- a/server/controllers/static.ts +++ b/server/controllers/static.ts @@ -1,5 +1,15 @@ import * as cors from 'cors' import * as express from 'express' +import { join } from 'path' +import { getRegisteredPlugins, getRegisteredThemes } from '@server/controllers/api/config' +import { serveIndexHTML } from '@server/lib/client-html' +import { getTorrentFilePath, getVideoFilePath } from '@server/lib/video-paths' +import { MVideoFile, MVideoFullLight } from '@server/types/models' +import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' +import { VideoStreamingPlaylistType } from '@shared/models/videos/video-streaming-playlist.type' +import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo' +import { root } from '../helpers/core-utils' +import { CONFIG, isEmailEnabled } from '../initializers/config' import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, @@ -11,24 +21,13 @@ import { STATIC_PATHS, WEBSERVER } from '../initializers/constants' -import { cacheRoute } from '../middlewares/cache' +import { getThemeOrDefault } from '../lib/plugins/theme-utils' +import { getEnabledResolutions } from '../lib/video-transcoding' import { asyncMiddleware, videosDownloadValidator } from '../middlewares' -import { VideoModel } from '../models/video/video' +import { cacheRoute } from '../middlewares/cache' import { UserModel } from '../models/account/user' +import { VideoModel } from '../models/video/video' import { VideoCommentModel } from '../models/video/video-comment' -import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo' -import { join } from 'path' -import { root } from '../helpers/core-utils' -import { getEnabledResolutions } from '../lib/video-transcoding' -import { CONFIG, isEmailEnabled } from '../initializers/config' -import { getPreview, getVideoCaption } from './lazy-static' -import { VideoStreamingPlaylistType } from '@shared/models/videos/video-streaming-playlist.type' -import { MVideoFile, MVideoFullLight } from '@server/types/models' -import { getTorrentFilePath, getVideoFilePath } from '@server/lib/video-paths' -import { getThemeOrDefault } from '../lib/plugins/theme-utils' -import { getRegisteredPlugins, getRegisteredThemes } from '@server/controllers/api/config' -import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' -import { serveIndexHTML } from '@server/lib/client-html' const staticRouter = express.Router() diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 7beaca238..a9f7a8e58 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 570 +const LAST_MIGRATION_VERSION = 575 // --------------------------------------------------------------------------- diff --git a/server/initializers/migrations/0575-duplicate-thumbnail.ts b/server/initializers/migrations/0575-duplicate-thumbnail.ts new file mode 100644 index 000000000..4dbbe71d4 --- /dev/null +++ b/server/initializers/migrations/0575-duplicate-thumbnail.ts @@ -0,0 +1,24 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize + db: any +}): Promise { + { + const query = 'DELETE FROM "thumbnail" s1 ' + + 'USING (SELECT MIN(id) as id, "filename", "type" FROM "thumbnail" GROUP BY "filename", "type" HAVING COUNT(*) > 1) s2 ' + + 'WHERE s1."filename" = s2."filename" AND s1."type" = s2."type" AND s1.id <> s2.id' + await utils.sequelize.query(query) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 8545e5bad..b5a199e67 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -5,6 +5,7 @@ import { join } from 'path' import * as request from 'request' import * as sequelize from 'sequelize' import { VideoLiveModel } from '@server/models/video/video-live' +import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' import { ActivityHashTagObject, ActivityMagnetUrlObject, @@ -15,7 +16,7 @@ import { ActivityUrlObject, ActivityVideoUrlObject } from '../../../shared/index' -import { VideoObject } from '../../../shared/models/activitypub/objects' +import { ActivityIconObject, VideoObject } from '../../../shared/models/activitypub/objects' import { VideoPrivacy } from '../../../shared/models/videos' import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' @@ -76,7 +77,6 @@ import { sendCreateVideo, sendUpdateVideo } from './send' import { addVideoShares, shareVideoByServerAndChannel } from './share' import { addVideoComments } from './video-comments' import { createRates } from './video-rates' -import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVideo: boolean, transaction?: sequelize.Transaction) { const video = videoArg as MVideoAP @@ -360,7 +360,7 @@ async function updateVideoFromAP (options: { if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel, t) if (videoUpdated.getPreview()) { - const previewUrl = videoUpdated.getPreview().getFileUrl(videoUpdated) + const previewUrl = getPreviewUrl(getPreviewFromIcons(videoObject), video) const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE) await videoUpdated.addAndSaveThumbnail(previewModel, t) } @@ -597,9 +597,7 @@ async function createVideo (videoObject: VideoObject, channel: MChannelAccountLi if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) const previewIcon = getPreviewFromIcons(videoObject) - const previewUrl = previewIcon - ? previewIcon.url - : buildRemoteVideoBaseUrl(videoCreated, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName())) + const previewUrl = getPreviewUrl(previewIcon, videoCreated) const previewModel = createPlaceholderThumbnail(previewUrl, videoCreated, ThumbnailType.PREVIEW, PREVIEWS_SIZE) if (thumbnailModel) await videoCreated.addAndSaveThumbnail(previewModel, t) @@ -822,7 +820,11 @@ function getThumbnailFromIcons (videoObject: VideoObject) { function getPreviewFromIcons (videoObject: VideoObject) { const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth) - // FIXME: don't put a fallback here for compatibility with PeerTube <2.2 - return maxBy(validIcons, 'width') } + +function getPreviewUrl (previewIcon: ActivityIconObject, video: MVideoAccountLight) { + return previewIcon + ? previewIcon.url + : buildRemoteVideoBaseUrl(video, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName())) +} diff --git a/server/lib/files-cache/videos-preview-cache.ts b/server/lib/files-cache/videos-preview-cache.ts index d0d4fc5b5..51146d718 100644 --- a/server/lib/files-cache/videos-preview-cache.ts +++ b/server/lib/files-cache/videos-preview-cache.ts @@ -3,6 +3,9 @@ import { FILES_CACHE } from '../../initializers/constants' import { VideoModel } from '../../models/video/video' import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' import { doRequestAndSaveToFile } from '@server/helpers/requests' +import { ThumbnailModel } from '@server/models/video/thumbnail' +import { ThumbnailType } from '@shared/models' +import { logger } from '@server/helpers/logger' class VideosPreviewCache extends AbstractVideoStaticFileCache { @@ -16,13 +19,13 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache { return this.instance || (this.instance = new this()) } - async getFilePathImpl (videoUUID: string) { - const video = await VideoModel.loadByUUID(videoUUID) - if (!video) return undefined + async getFilePathImpl (filename: string) { + const thumbnail = await ThumbnailModel.loadWithVideoByName(filename, ThumbnailType.PREVIEW) + if (!thumbnail) return undefined - if (video.isOwned()) return { isOwned: true, path: video.getPreview().getPath() } + if (thumbnail.Video.isOwned()) return { isOwned: true, path: thumbnail.getPath() } - return this.loadRemoteFile(videoUUID) + return this.loadRemoteFile(thumbnail.Video.uuid) } protected async loadRemoteFile (key: string) { @@ -37,6 +40,8 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache { const remoteUrl = preview.getFileUrl(video) await doRequestAndSaveToFile({ uri: remoteUrl }, destPath) + logger.debug('Fetched remote preview %s to %s.', remoteUrl, destPath) + return { isOwned: false, path: destPath } } } diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts index dc86423f8..740b83acb 100644 --- a/server/lib/thumbnail.ts +++ b/server/lib/thumbnail.ts @@ -27,18 +27,28 @@ function createPlaylistMiniatureFromExisting ( return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, automaticallyGenerated, existingThumbnail }) } -function createPlaylistMiniatureFromUrl (fileUrl: string, playlist: MVideoPlaylistThumbnail, size?: ImageSize) { +function createPlaylistMiniatureFromUrl (downloadUrl: string, playlist: MVideoPlaylistThumbnail, size?: ImageSize) { const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size) const type = ThumbnailType.MINIATURE - const thumbnailCreator = () => downloadImage(fileUrl, basePath, filename, { width, height }) + // Only save the file URL if it is a remote playlist + const fileUrl = playlist.isOwned() + ? null + : downloadUrl + + const thumbnailCreator = () => downloadImage(downloadUrl, basePath, filename, { width, height }) return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl }) } -function createVideoMiniatureFromUrl (fileUrl: string, video: MVideoThumbnail, type: ThumbnailType, size?: ImageSize) { +function createVideoMiniatureFromUrl (downloadUrl: string, video: MVideoThumbnail, type: ThumbnailType, size?: ImageSize) { const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size) - const thumbnailCreator = () => downloadImage(fileUrl, basePath, filename, { width, height }) + // Only save the file URL if it is a remote video + const fileUrl = video.isOwned() + ? null + : downloadUrl + + const thumbnailCreator = () => downloadImage(downloadUrl, basePath, filename, { width, height }) return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl }) } diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts index 6878a3155..3cad6c668 100644 --- a/server/models/video/thumbnail.ts +++ b/server/models/video/thumbnail.ts @@ -1,3 +1,4 @@ +import { remove } from 'fs-extra' import { join } from 'path' import { AfterDestroy, @@ -12,15 +13,14 @@ import { Table, UpdatedAt } from 'sequelize-typescript' -import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants' +import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' +import { MThumbnailVideo, MVideoAccountLight } from '@server/types/models' +import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' import { logger } from '../../helpers/logger' -import { remove } from 'fs-extra' import { CONFIG } from '../../initializers/config' +import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants' import { VideoModel } from './video' import { VideoPlaylistModel } from './video-playlist' -import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' -import { MVideoAccountLight } from '@server/types/models' -import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' @Table({ tableName: 'thumbnail', @@ -31,6 +31,10 @@ import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' { fields: [ 'videoPlaylistId' ], unique: true + }, + { + fields: [ 'filename', 'type' ], + unique: true } ] }) @@ -114,20 +118,23 @@ export class ThumbnailModel extends Model { .catch(err => logger.error('Cannot remove thumbnail file %s.', instance.filename, err)) } - static loadByName (filename: string) { + static loadWithVideoByName (filename: string, thumbnailType: ThumbnailType): Promise { const query = { where: { - filename - } + filename, + type: thumbnailType + }, + include: [ + { + model: VideoModel.unscoped(), + required: true + } + ] } return ThumbnailModel.findOne(query) } - static generateDefaultPreviewName (videoUUID: string) { - return videoUUID + '.jpg' - } - getFileUrl (video: MVideoAccountLight) { const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 14e80a3ba..3321deed3 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -130,6 +130,7 @@ import { VideoShareModel } from './video-share' import { VideoStreamingPlaylistModel } from './video-streaming-playlist' import { VideoTagModel } from './video-tag' import { VideoViewModel } from './video-view' +import { v4 as uuidv4 } from 'uuid' export enum ScopeNames { AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', @@ -1827,7 +1828,7 @@ export class VideoModel extends Model { } generateThumbnailName () { - return this.uuid + '.jpg' + return uuidv4() + '.jpg' } getMiniature () { @@ -1837,7 +1838,7 @@ export class VideoModel extends Model { } generatePreviewName () { - return this.uuid + '.jpg' + return uuidv4() + '.jpg' } hasPreview () { diff --git a/server/types/models/video/thumbnail.ts b/server/types/models/video/thumbnail.ts index c03ba55ac..81a29e062 100644 --- a/server/types/models/video/thumbnail.ts +++ b/server/types/models/video/thumbnail.ts @@ -1,3 +1,15 @@ +import { PickWith } from '@shared/core-utils' import { ThumbnailModel } from '../../../models/video/thumbnail' +import { MVideo } from './video' + +type Use = PickWith + +// ############################################################################ export type MThumbnail = Omit + +// ############################################################################ + +export type MThumbnailVideo = + MThumbnail & + Use<'Video', MVideo> -- 2.41.0