1
0
Fork 0

Support lazy download of remote video miniatures

This commit is contained in:
Chocobozzz 2023-06-07 08:53:14 +02:00
parent f162d32da0
commit bafaba0bcd
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
15 changed files with 152 additions and 114 deletions

View File

@ -6,6 +6,7 @@ import { FILES_CACHE, LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/
import { import {
AvatarPermanentFileCache, AvatarPermanentFileCache,
VideoCaptionsSimpleFileCache, VideoCaptionsSimpleFileCache,
VideoMiniaturePermanentFileCache,
VideoPreviewsSimpleFileCache, VideoPreviewsSimpleFileCache,
VideoStoryboardsSimpleFileCache, VideoStoryboardsSimpleFileCache,
VideoTorrentsSimpleFileCache VideoTorrentsSimpleFileCache
@ -39,6 +40,12 @@ lazyStaticRouter.use(
handleStaticError handleStaticError
) )
lazyStaticRouter.use(
LAZY_STATIC_PATHS.THUMBNAILS + ':filename',
asyncMiddleware(getThumbnail),
handleStaticError
)
lazyStaticRouter.use( lazyStaticRouter.use(
LAZY_STATIC_PATHS.PREVIEWS + ':filename', LAZY_STATIC_PATHS.PREVIEWS + ':filename',
asyncMiddleware(getPreview), asyncMiddleware(getPreview),
@ -72,7 +79,6 @@ export {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const avatarPermanentFileCache = new AvatarPermanentFileCache() const avatarPermanentFileCache = new AvatarPermanentFileCache()
function getActorImage (req: express.Request, res: express.Response, next: express.NextFunction) { function getActorImage (req: express.Request, res: express.Response, next: express.NextFunction) {
@ -81,6 +87,17 @@ function getActorImage (req: express.Request, res: express.Response, next: expre
return avatarPermanentFileCache.lazyServe({ filename, res, next }) return avatarPermanentFileCache.lazyServe({ filename, res, next })
} }
// ---------------------------------------------------------------------------
const videoMiniaturePermanentFileCache = new VideoMiniaturePermanentFileCache()
function getThumbnail (req: express.Request, res: express.Response, next: express.NextFunction) {
const filename = req.params.filename
return videoMiniaturePermanentFileCache.lazyServe({ filename, res, next })
}
// ---------------------------------------------------------------------------
async function getPreview (req: express.Request, res: express.Response) { async function getPreview (req: express.Request, res: express.Response) {
const result = await VideoPreviewsSimpleFileCache.Instance.getFilePath(req.params.filename) const result = await VideoPreviewsSimpleFileCache.Instance.getFilePath(req.params.filename)
if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end() if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()

View File

@ -72,7 +72,7 @@ staticRouter.use(
handleStaticError handleStaticError
) )
// Thumbnails path for express // FIXME: deprecated in v6, to remove
const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR
staticRouter.use( staticRouter.use(
STATIC_PATHS.THUMBNAILS, STATIC_PATHS.THUMBNAILS,

View File

@ -747,6 +747,7 @@ const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
// Express static paths (router) // Express static paths (router)
const STATIC_PATHS = { const STATIC_PATHS = {
// TODO: deprecated in v6, to remove
THUMBNAILS: '/static/thumbnails/', THUMBNAILS: '/static/thumbnails/',
WEBSEED: '/static/webseed/', WEBSEED: '/static/webseed/',
@ -765,6 +766,7 @@ const STATIC_DOWNLOAD_PATHS = {
HLS_VIDEOS: '/download/streaming-playlists/hls/videos/' HLS_VIDEOS: '/download/streaming-playlists/hls/videos/'
} }
const LAZY_STATIC_PATHS = { const LAZY_STATIC_PATHS = {
THUMBNAILS: '/lazy-static/thumbnails/',
BANNERS: '/lazy-static/banners/', BANNERS: '/lazy-static/banners/',
AVATARS: '/lazy-static/avatars/', AVATARS: '/lazy-static/avatars/',
PREVIEWS: '/lazy-static/previews/', PREVIEWS: '/lazy-static/previews/',

View File

@ -1,7 +1,7 @@
import { CreationAttributes, Transaction } from 'sequelize/types' import { CreationAttributes, Transaction } from 'sequelize/types'
import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils' import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils'
import { logger, LoggerTagsFn } from '@server/helpers/logger' import { logger, LoggerTagsFn } from '@server/helpers/logger'
import { updateRemoteThumbnail, updateVideoMiniatureFromUrl } from '@server/lib/thumbnail' import { updateRemoteThumbnail } from '@server/lib/thumbnail'
import { setVideoTags } from '@server/lib/video' import { setVideoTags } from '@server/lib/video'
import { StoryboardModel } from '@server/models/video/storyboard' import { StoryboardModel } from '@server/models/video/storyboard'
import { VideoCaptionModel } from '@server/models/video/video-caption' import { VideoCaptionModel } from '@server/models/video/video-caption'
@ -11,7 +11,6 @@ import { VideoStreamingPlaylistModel } from '@server/models/video/video-streamin
import { import {
MStreamingPlaylistFiles, MStreamingPlaylistFiles,
MStreamingPlaylistFilesVideo, MStreamingPlaylistFilesVideo,
MThumbnail,
MVideoCaption, MVideoCaption,
MVideoFile, MVideoFile,
MVideoFullLight, MVideoFullLight,
@ -42,16 +41,22 @@ export abstract class APVideoAbstractBuilder {
return getOrCreateAPActor(channel.id, 'all') return getOrCreateAPActor(channel.id, 'all')
} }
protected tryToGenerateThumbnail (video: MVideoThumbnail): Promise<MThumbnail> { protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
return updateVideoMiniatureFromUrl({ const miniatureIcon = getThumbnailFromIcons(this.videoObject)
downloadUrl: getThumbnailFromIcons(this.videoObject).url, if (!miniatureIcon) {
video, logger.warn('Cannot find thumbnail in video object', { object: this.videoObject })
type: ThumbnailType.MINIATURE
}).catch(err => {
logger.warn('Cannot generate thumbnail of %s.', this.videoObject.id, { err, ...this.lTags() })
return undefined return undefined
}
const miniatureModel = updateRemoteThumbnail({
fileUrl: miniatureIcon.url,
video,
type: ThumbnailType.MINIATURE,
size: miniatureIcon,
onDisk: false // Lazy download remote thumbnails
}) })
await video.addAndSaveThumbnail(miniatureModel, t)
} }
protected async setPreview (video: MVideoFullLight, t?: Transaction) { protected async setPreview (video: MVideoFullLight, t?: Transaction) {

View File

@ -4,7 +4,7 @@ import { sequelizeTypescript } from '@server/initializers/database'
import { Hooks } from '@server/lib/plugins/hooks' import { Hooks } from '@server/lib/plugins/hooks'
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist' import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
import { VideoModel } from '@server/models/video/video' import { VideoModel } from '@server/models/video/video'
import { MThumbnail, MVideoFullLight, MVideoThumbnail } from '@server/types/models' import { MVideoFullLight, MVideoThumbnail } from '@server/types/models'
import { VideoObject } from '@shared/models' import { VideoObject } from '@shared/models'
import { APVideoAbstractBuilder } from './abstract-builder' import { APVideoAbstractBuilder } from './abstract-builder'
import { getVideoAttributesFromObject } from './object-to-model-attributes' import { getVideoAttributesFromObject } from './object-to-model-attributes'
@ -27,64 +27,37 @@ export class APVideoCreator extends APVideoAbstractBuilder {
const videoData = getVideoAttributesFromObject(channel, this.videoObject, this.videoObject.to) const videoData = getVideoAttributesFromObject(channel, this.videoObject, this.videoObject.to)
const video = VideoModel.build({ ...videoData, likes: 0, dislikes: 0 }) as MVideoThumbnail const video = VideoModel.build({ ...videoData, likes: 0, dislikes: 0 }) as MVideoThumbnail
const promiseThumbnail = this.tryToGenerateThumbnail(video)
let thumbnailModel: MThumbnail
if (waitThumbnail === true) {
thumbnailModel = await promiseThumbnail
}
const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => { const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
try { const videoCreated = await video.save({ transaction: t }) as MVideoFullLight
const videoCreated = await video.save({ transaction: t }) as MVideoFullLight videoCreated.VideoChannel = channel
videoCreated.VideoChannel = channel
if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) await this.setThumbnail(videoCreated, t)
await this.setPreview(videoCreated, t)
await this.setWebTorrentFiles(videoCreated, t)
await this.setStreamingPlaylists(videoCreated, t)
await this.setTags(videoCreated, t)
await this.setTrackers(videoCreated, t)
await this.insertOrReplaceCaptions(videoCreated, t)
await this.insertOrReplaceLive(videoCreated, t)
await this.insertOrReplaceStoryboard(videoCreated, t)
await this.setPreview(videoCreated, t) // We added a video in this channel, set it as updated
await this.setWebTorrentFiles(videoCreated, t) await channel.setAsUpdated(t)
await this.setStreamingPlaylists(videoCreated, t)
await this.setTags(videoCreated, t)
await this.setTrackers(videoCreated, t)
await this.insertOrReplaceCaptions(videoCreated, t)
await this.insertOrReplaceLive(videoCreated, t)
await this.insertOrReplaceStoryboard(videoCreated, t)
// We added a video in this channel, set it as updated const autoBlacklisted = await autoBlacklistVideoIfNeeded({
await channel.setAsUpdated(t) video: videoCreated,
user: undefined,
const autoBlacklisted = await autoBlacklistVideoIfNeeded({ isRemote: true,
video: videoCreated, isNew: true,
user: undefined, transaction: t
isRemote: true,
isNew: true,
transaction: t
})
logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid, this.lTags())
Hooks.runAction('action:activity-pub.remote-video.created', { video: videoCreated, videoAPObject: this.videoObject })
return { autoBlacklisted, videoCreated }
} catch (err) {
// FIXME: Use rollback hook when https://github.com/sequelize/sequelize/pull/13038 is released
if (thumbnailModel) await thumbnailModel.removeThumbnail()
throw err
}
})
if (waitThumbnail === false) {
// Error is already caught above
// eslint-disable-next-line @typescript-eslint/no-floating-promises
promiseThumbnail.then(thumbnailModel => {
if (!thumbnailModel) return
thumbnailModel = videoCreated.id
return thumbnailModel.save()
}) })
}
logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid, this.lTags())
Hooks.runAction('action:activity-pub.remote-video.created', { video: videoCreated, videoAPObject: this.videoObject })
return { autoBlacklisted, videoCreated }
})
return { autoBlacklisted, videoCreated } return { autoBlacklisted, videoCreated }
} }

View File

@ -41,7 +41,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
try { try {
const channelActor = await this.getOrCreateVideoChannelFromVideoObject() const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
const thumbnailModel = await this.tryToGenerateThumbnail(this.video) const thumbnailModel = await this.setThumbnail(this.video)
this.checkChannelUpdateOrThrow(channelActor) this.checkChannelUpdateOrThrow(channelActor)
@ -58,8 +58,13 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)), runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)), runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)), runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)),
this.setOrDeleteLive(videoUpdated), runInReadCommittedTransaction(t => {
this.setPreview(videoUpdated) return Promise.all([
this.setPreview(videoUpdated, t),
this.setThumbnail(videoUpdated, t)
])
}),
this.setOrDeleteLive(videoUpdated)
]) ])
await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t)) await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))

View File

@ -1,10 +1,10 @@
import { CONFIG } from '@server/initializers/config'
import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants' import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants'
import { ActorImageModel } from '@server/models/actor/actor-image' import { ActorImageModel } from '@server/models/actor/actor-image'
import { MActorImage } from '@server/types/models' import { MActorImage } from '@server/types/models'
import { AbstractPermanentFileCache } from './shared' import { AbstractPermanentFileCache } from './shared'
import { CONFIG } from '@server/initializers/config'
export class AvatarPermanentFileCache extends AbstractPermanentFileCache<ActorImageModel> { export class AvatarPermanentFileCache extends AbstractPermanentFileCache<MActorImage> {
constructor () { constructor () {
super(CONFIG.STORAGE.ACTOR_IMAGES) super(CONFIG.STORAGE.ACTOR_IMAGES)

View File

@ -1,4 +1,5 @@
export * from './avatar-permanent-file-cache' export * from './avatar-permanent-file-cache'
export * from './video-miniature-permanent-file-cache'
export * from './video-captions-simple-file-cache' export * from './video-captions-simple-file-cache'
export * from './video-previews-simple-file-cache' export * from './video-previews-simple-file-cache'
export * from './video-storyboards-simple-file-cache' export * from './video-storyboards-simple-file-cache'

View File

@ -0,0 +1,28 @@
import { CONFIG } from '@server/initializers/config'
import { THUMBNAILS_SIZE } from '@server/initializers/constants'
import { ThumbnailModel } from '@server/models/video/thumbnail'
import { MThumbnail } from '@server/types/models'
import { ThumbnailType } from '@shared/models'
import { AbstractPermanentFileCache } from './shared'
export class VideoMiniaturePermanentFileCache extends AbstractPermanentFileCache<MThumbnail> {
constructor () {
super(CONFIG.STORAGE.THUMBNAILS_DIR)
}
protected loadModel (filename: string) {
return ThumbnailModel.loadByFilename(filename, ThumbnailType.MINIATURE)
}
protected getImageSize (image: MThumbnail): { width: number, height: number } {
if (image.width && image.height) {
return {
height: image.height,
width: image.width
}
}
return THUMBNAILS_SIZE
}
}

View File

@ -60,38 +60,6 @@ function updatePlaylistMiniatureFromUrl (options: {
return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true }) return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true })
} }
function updateVideoMiniatureFromUrl (options: {
downloadUrl: string
video: MVideoThumbnail
type: ThumbnailType
size?: ImageSize
}) {
const { downloadUrl, video, type, size } = options
const { filename: updatedFilename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
// Only save the file URL if it is a remote video
const fileUrl = video.isOwned()
? null
: downloadUrl
const thumbnailUrlChanged = hasThumbnailUrlChanged(existingThumbnail, downloadUrl, video)
// Do not change the thumbnail filename if the file did not change
const filename = thumbnailUrlChanged
? updatedFilename
: existingThumbnail.filename
const thumbnailCreator = () => {
if (thumbnailUrlChanged) {
return downloadImageFromWorker({ url: downloadUrl, destDir: basePath, destName: filename, size: { width, height } })
}
return Promise.resolve()
}
return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true })
}
function updateLocalVideoMiniatureFromExisting (options: { function updateLocalVideoMiniatureFromExisting (options: {
inputPath: string inputPath: string
video: MVideoThumbnail video: MVideoThumbnail
@ -157,6 +125,40 @@ function generateLocalVideoMiniature (options: {
}) })
} }
// ---------------------------------------------------------------------------
function updateVideoMiniatureFromUrl (options: {
downloadUrl: string
video: MVideoThumbnail
type: ThumbnailType
size?: ImageSize
}) {
const { downloadUrl, video, type, size } = options
const { filename: updatedFilename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
// Only save the file URL if it is a remote video
const fileUrl = video.isOwned()
? null
: downloadUrl
const thumbnailUrlChanged = hasThumbnailUrlChanged(existingThumbnail, downloadUrl, video)
// Do not change the thumbnail filename if the file did not change
const filename = thumbnailUrlChanged
? updatedFilename
: existingThumbnail.filename
const thumbnailCreator = () => {
if (thumbnailUrlChanged) {
return downloadImageFromWorker({ url: downloadUrl, destDir: basePath, destName: filename, size: { width, height } })
}
return Promise.resolve()
}
return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true })
}
function updateRemoteThumbnail (options: { function updateRemoteThumbnail (options: {
fileUrl: string fileUrl: string
video: MVideoThumbnail video: MVideoThumbnail
@ -167,12 +169,10 @@ function updateRemoteThumbnail (options: {
const { fileUrl, video, type, size, onDisk } = options const { fileUrl, video, type, size, onDisk } = options
const { filename: generatedFilename, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size) const { filename: generatedFilename, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
const thumbnailUrlChanged = hasThumbnailUrlChanged(existingThumbnail, fileUrl, video)
const thumbnail = existingThumbnail || new ThumbnailModel() const thumbnail = existingThumbnail || new ThumbnailModel()
// Do not change the thumbnail filename if the file did not change // Do not change the thumbnail filename if the file did not change
if (thumbnailUrlChanged) { if (hasThumbnailUrlChanged(existingThumbnail, fileUrl, video)) {
thumbnail.filename = generatedFilename thumbnail.filename = generatedFilename
} }

View File

@ -262,13 +262,16 @@ async function forgeThumbnail ({ inputPath, video, downloadUrl, type }: {
type, type,
automaticallyGenerated: false automaticallyGenerated: false
}) })
} else if (downloadUrl) { }
if (downloadUrl) {
try { try {
return await updateVideoMiniatureFromUrl({ downloadUrl, video, type }) return await updateVideoMiniatureFromUrl({ downloadUrl, video, type })
} catch (err) { } catch (err) {
logger.warn('Cannot process thumbnail %s from youtube-dl.', downloadUrl, { err }) logger.warn('Cannot process thumbnail %s from youtube-dl.', downloadUrl, { err })
} }
} }
return null return null
} }

View File

@ -21,7 +21,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config' import { CONFIG } from '../../initializers/config'
import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants' import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
import { VideoModel } from './video' import { VideoModel } from './video'
import { VideoPlaylistModel } from './video-playlist' import { VideoPlaylistModel } from './video-playlist'
@ -110,7 +110,7 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
[ThumbnailType.MINIATURE]: { [ThumbnailType.MINIATURE]: {
label: 'miniature', label: 'miniature',
directory: CONFIG.STORAGE.THUMBNAILS_DIR, directory: CONFIG.STORAGE.THUMBNAILS_DIR,
staticPath: STATIC_PATHS.THUMBNAILS staticPath: LAZY_STATIC_PATHS.THUMBNAILS
}, },
[ThumbnailType.PREVIEW]: { [ThumbnailType.PREVIEW]: {
label: 'preview', label: 'preview',
@ -201,4 +201,8 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
this.previousThumbnailFilename = undefined this.previousThumbnailFilename = undefined
} }
isOwned () {
return !this.fileUrl
}
} }

View File

@ -32,7 +32,7 @@ import {
import { import {
ACTIVITY_PUB, ACTIVITY_PUB,
CONSTRAINTS_FIELDS, CONSTRAINTS_FIELDS,
STATIC_PATHS, LAZY_STATIC_PATHS,
THUMBNAILS_SIZE, THUMBNAILS_SIZE,
VIDEO_PLAYLIST_PRIVACIES, VIDEO_PLAYLIST_PRIVACIES,
VIDEO_PLAYLIST_TYPES, VIDEO_PLAYLIST_TYPES,
@ -592,13 +592,13 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
getThumbnailUrl () { getThumbnailUrl () {
if (!this.hasThumbnail()) return null if (!this.hasThumbnail()) return null
return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename return WEBSERVER.URL + LAZY_STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
} }
getThumbnailStaticPath () { getThumbnailStaticPath () {
if (!this.hasThumbnail()) return null if (!this.hasThumbnail()) return null
return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename) return join(LAZY_STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
} }
getWatchStaticPath () { getWatchStaticPath () {

View File

@ -119,7 +119,7 @@ describe('Test video imports', function () {
expect(video.name).to.equal('small video - youtube') expect(video.name).to.equal('small video - youtube')
{ {
expect(video.thumbnailPath).to.match(new RegExp(`^/static/thumbnails/.+.jpg$`)) expect(video.thumbnailPath).to.match(new RegExp(`^/lazy-static/thumbnails/.+.jpg$`))
expect(video.previewPath).to.match(new RegExp(`^/lazy-static/previews/.+.jpg$`)) expect(video.previewPath).to.match(new RegExp(`^/lazy-static/previews/.+.jpg$`))
const suffix = mode === 'yt-dlp' const suffix = mode === 'yt-dlp'

View File

@ -7129,7 +7129,7 @@ components:
maxLength: 120 maxLength: 120
thumbnailPath: thumbnailPath:
type: string type: string
example: /static/thumbnails/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg example: /lazy-static/thumbnails/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg
previewPath: previewPath:
type: string type: string
example: /lazy-static/previews/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg example: /lazy-static/previews/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg