]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Generate a name for thumbnails
authorChocobozzz <me@florianbigard.com>
Fri, 12 Feb 2021 15:23:19 +0000 (16:23 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Tue, 16 Feb 2021 09:36:44 +0000 (10:36 +0100)
Allows aggressive caching

scripts/prune-storage.ts
server/controllers/lazy-static.ts
server/controllers/static.ts
server/initializers/constants.ts
server/initializers/migrations/0575-duplicate-thumbnail.ts [new file with mode: 0644]
server/lib/activitypub/videos.ts
server/lib/files-cache/videos-preview-cache.ts
server/lib/thumbnail.ts
server/models/video/thumbnail.ts
server/models/video/video.ts
server/types/models/video/thumbnail.ts

index 1def1d792f882d7098445c07f9420d15bbb4c02e..788d97997d2c1eadc971e1e6638c7ed912be386d 100755 (executable)
@@ -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) {
index 5c6369c9e5ed1a879d6f95fb75c7b7aa6564a04d..847d24fd479b3d9ecbb12bdf81cb3ce83b669b22 100644 (file)
@@ -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 })
index a645c496b181286b48576392c0c21a43cbddcd1d..2064857eb12d30099bc1d00d7f0f4be04be8e847 100644 (file)
@@ -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()
 
index 7beaca238b78277bb9424f62d835e605ebd2a750..a9f7a8e58ffb19a2f0799a8c0e21d03bfa1eb3ff 100644 (file)
@@ -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 (file)
index 0000000..4dbbe71
--- /dev/null
@@ -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<void> {
+  {
+    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
+}
index 8545e5badaa34b999a4ac08de2f37f0fb183328c..b5a199e675c2809c44613c4f3c4b47bc08b3cfac 100644 (file)
@@ -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()))
+}
index d0d4fc5b551f4b780aaf86d034624c1b6bacd370..51146d71843934ec4ce06605fc3d6b8245e9b54d 100644 (file)
@@ -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 <string> {
 
@@ -16,13 +19,13 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
     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 <string> {
     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 }
   }
 }
index dc86423f832d3e12474a0ef79afd4986852d8a57..740b83acb15414748f2068a3b905a737f90cd6fc 100644 (file)
@@ -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 })
 }
 
index 6878a3155b010120405fb6352451b1769900a27a..3cad6c668d0f3260d395b70c5d68a40359de2328 100644 (file)
@@ -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<MThumbnailVideo> {
     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
 
index 14e80a3ba5cffe8bdd32ae4310ace0ca16b83295..3321deed3aaac1a13235b6839bb81c447ce4edf4 100644 (file)
@@ -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 () {
index c03ba55ac61b6eb439d8c22688c0347df0911031..81a29e062abc47078e7768308d0c122f4bf448b8 100644 (file)
@@ -1,3 +1,15 @@
+import { PickWith } from '@shared/core-utils'
 import { ThumbnailModel } from '../../../models/video/thumbnail'
+import { MVideo } from './video'
+
+type Use<K extends keyof ThumbnailModel, M> = PickWith<ThumbnailModel, K, M>
+
+// ############################################################################
 
 export type MThumbnail = Omit<ThumbnailModel, 'Video' | 'VideoPlaylist'>
+
+// ############################################################################
+
+export type MThumbnailVideo =
+  MThumbnail &
+  Use<'Video', MVideo>