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 {
AvatarPermanentFileCache,
VideoCaptionsSimpleFileCache,
VideoMiniaturePermanentFileCache,
VideoPreviewsSimpleFileCache,
VideoStoryboardsSimpleFileCache,
VideoTorrentsSimpleFileCache
@ -39,6 +40,12 @@ lazyStaticRouter.use(
handleStaticError
)
lazyStaticRouter.use(
LAZY_STATIC_PATHS.THUMBNAILS + ':filename',
asyncMiddleware(getThumbnail),
handleStaticError
)
lazyStaticRouter.use(
LAZY_STATIC_PATHS.PREVIEWS + ':filename',
asyncMiddleware(getPreview),
@ -72,7 +79,6 @@ export {
}
// ---------------------------------------------------------------------------
const avatarPermanentFileCache = new AvatarPermanentFileCache()
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 })
}
// ---------------------------------------------------------------------------
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) {
const result = await VideoPreviewsSimpleFileCache.Instance.getFilePath(req.params.filename)
if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { sequelizeTypescript } from '@server/initializers/database'
import { Hooks } from '@server/lib/plugins/hooks'
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
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 { APVideoAbstractBuilder } from './abstract-builder'
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 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 => {
try {
const videoCreated = await video.save({ transaction: t }) as MVideoFullLight
videoCreated.VideoChannel = channel
const videoCreated = await video.save({ transaction: t }) as MVideoFullLight
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)
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)
// We added a video in this channel, set it as updated
await channel.setAsUpdated(t)
// We added a video in this channel, set it as updated
await channel.setAsUpdated(t)
const autoBlacklisted = await autoBlacklistVideoIfNeeded({
video: videoCreated,
user: undefined,
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()
const autoBlacklisted = await autoBlacklistVideoIfNeeded({
video: videoCreated,
user: undefined,
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 }
})
return { autoBlacklisted, videoCreated }
}

View File

@ -41,7 +41,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
try {
const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
const thumbnailModel = await this.tryToGenerateThumbnail(this.video)
const thumbnailModel = await this.setThumbnail(this.video)
this.checkChannelUpdateOrThrow(channelActor)
@ -58,8 +58,13 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)),
this.setOrDeleteLive(videoUpdated),
this.setPreview(videoUpdated)
runInReadCommittedTransaction(t => {
return Promise.all([
this.setPreview(videoUpdated, t),
this.setThumbnail(videoUpdated, t)
])
}),
this.setOrDeleteLive(videoUpdated)
])
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 { ActorImageModel } from '@server/models/actor/actor-image'
import { MActorImage } from '@server/types/models'
import { AbstractPermanentFileCache } from './shared'
import { CONFIG } from '@server/initializers/config'
export class AvatarPermanentFileCache extends AbstractPermanentFileCache<ActorImageModel> {
export class AvatarPermanentFileCache extends AbstractPermanentFileCache<MActorImage> {
constructor () {
super(CONFIG.STORAGE.ACTOR_IMAGES)

View File

@ -1,4 +1,5 @@
export * from './avatar-permanent-file-cache'
export * from './video-miniature-permanent-file-cache'
export * from './video-captions-simple-file-cache'
export * from './video-previews-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 })
}
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: {
inputPath: string
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: {
fileUrl: string
video: MVideoThumbnail
@ -167,12 +169,10 @@ function updateRemoteThumbnail (options: {
const { fileUrl, video, type, size, onDisk } = options
const { filename: generatedFilename, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
const thumbnailUrlChanged = hasThumbnailUrlChanged(existingThumbnail, fileUrl, video)
const thumbnail = existingThumbnail || new ThumbnailModel()
// Do not change the thumbnail filename if the file did not change
if (thumbnailUrlChanged) {
if (hasThumbnailUrlChanged(existingThumbnail, fileUrl, video)) {
thumbnail.filename = generatedFilename
}

View File

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

View File

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

View File

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

View File

@ -119,7 +119,7 @@ describe('Test video imports', function () {
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$`))
const suffix = mode === 'yt-dlp'

View File

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