mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2024-11-08 22:53:22 +03:00
Support lazy download of remote video miniatures
This commit is contained in:
parent
f162d32da0
commit
bafaba0bcd
@ -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()
|
||||
|
@ -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,
|
||||
|
@ -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/',
|
||||
|
@ -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) {
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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 () {
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user