From e8bafea35bc930cb8ac5b2d521a188642a1adffe Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 17 Apr 2019 10:07:00 +0200 Subject: Create a dedicated table to track video thumbnails --- server/models/video/thumbnail.ts | 116 ++++++++++++++++++++++ server/models/video/video-format-utils.ts | 8 +- server/models/video/video-playlist.ts | 85 ++++++++++------ server/models/video/video.ts | 155 +++++++++++++++++------------- 4 files changed, 266 insertions(+), 98 deletions(-) create mode 100644 server/models/video/thumbnail.ts (limited to 'server/models') diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts new file mode 100644 index 000000000..baa5533ac --- /dev/null +++ b/server/models/video/thumbnail.ts @@ -0,0 +1,116 @@ +import { join } from 'path' +import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { STATIC_PATHS, WEBSERVER } from '../../initializers/constants' +import { logger } from '../../helpers/logger' +import { remove } from 'fs-extra' +import { CONFIG } from '../../initializers/config' +import { VideoModel } from './video' +import { VideoPlaylistModel } from './video-playlist' +import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' + +@Table({ + tableName: 'thumbnail', + indexes: [ + { + fields: [ 'videoId' ] + }, + { + fields: [ 'videoPlaylistId' ], + unique: true + } + ] +}) +export class ThumbnailModel extends Model { + + @AllowNull(false) + @Column + filename: string + + @AllowNull(true) + @Default(null) + @Column + height: number + + @AllowNull(true) + @Default(null) + @Column + width: number + + @AllowNull(false) + @Column + type: ThumbnailType + + @AllowNull(true) + @Column + url: string + + @ForeignKey(() => VideoModel) + @Column + videoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'CASCADE' + }) + Video: VideoModel + + @ForeignKey(() => VideoPlaylistModel) + @Column + videoPlaylistId: number + + @BelongsTo(() => VideoPlaylistModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'CASCADE' + }) + VideoPlaylist: VideoPlaylistModel + + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + private static types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = { + [ThumbnailType.THUMBNAIL]: { + label: 'thumbnail', + directory: CONFIG.STORAGE.THUMBNAILS_DIR, + staticPath: STATIC_PATHS.THUMBNAILS + }, + [ThumbnailType.PREVIEW]: { + label: 'preview', + directory: CONFIG.STORAGE.PREVIEWS_DIR, + staticPath: STATIC_PATHS.PREVIEWS + } + } + + @AfterDestroy + static removeFilesAndSendDelete (instance: ThumbnailModel) { + logger.info('Removing %s file %s.', ThumbnailModel.types[instance.type].label, instance.filename) + + // Don't block the transaction + instance.removeThumbnail() + .catch(err => logger.error('Cannot remove thumbnail file %s.', instance.filename, err)) + } + + static generateDefaultPreviewName (videoUUID: string) { + return videoUUID + '.jpg' + } + + getUrl () { + if (this.url) return this.url + + const staticPath = ThumbnailModel.types[this.type].staticPath + return WEBSERVER.URL + staticPath + this.filename + } + + removeThumbnail () { + const directory = ThumbnailModel.types[this.type].directory + const thumbnailPath = join(directory, this.filename) + + return remove(thumbnailPath) + } +} diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index 64771b1ff..89992a5a8 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts @@ -7,7 +7,7 @@ import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects' -import { MIMETYPES, THUMBNAILS_SIZE, WEBSERVER } from '../../initializers/constants' +import { MIMETYPES, WEBSERVER } from '../../initializers/constants' import { VideoCaptionModel } from './video-caption' import { getVideoCommentsActivityPubUrl, @@ -326,10 +326,10 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject { subtitleLanguage, icon: { type: 'Image', - url: video.getThumbnailUrl(baseUrlHttp), + url: video.getThumbnail().getUrl(), mediaType: 'image/jpeg', - width: THUMBNAILS_SIZE.width, - height: THUMBNAILS_SIZE.height + width: video.getThumbnail().width, + height: video.getThumbnail().height }, url, likes: getVideoLikesActivityPubUrl(video), diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index 0725b752a..073609c24 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts @@ -1,6 +1,5 @@ import { AllowNull, - BeforeDestroy, BelongsTo, Column, CreatedAt, @@ -8,6 +7,7 @@ import { Default, ForeignKey, HasMany, + HasOne, Is, IsUUID, Model, @@ -40,16 +40,16 @@ import { join } from 'path' import { VideoPlaylistElementModel } from './video-playlist-element' import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' import { activityPubCollectionPagination } from '../../helpers/activitypub' -import { remove } from 'fs-extra' -import { logger } from '../../helpers/logger' import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model' -import { CONFIG } from '../../initializers/config' +import { ThumbnailModel } from './thumbnail' +import { ActivityIconObject } from '../../../shared/models/activitypub/objects' enum ScopeNames { AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH', WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY', WITH_ACCOUNT = 'WITH_ACCOUNT', + WITH_THUMBNAIL = 'WITH_THUMBNAIL', WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL' } @@ -62,6 +62,14 @@ type AvailableForListOptions = { } @Scopes({ + [ ScopeNames.WITH_THUMBNAIL ]: { + include: [ + { + model: () => ThumbnailModel, + required: false + } + ] + }, [ ScopeNames.WITH_VIDEOS_LENGTH ]: { attributes: { include: [ @@ -256,12 +264,15 @@ export class VideoPlaylistModel extends Model { }) VideoPlaylistElements: VideoPlaylistElementModel[] - @BeforeDestroy - static async removeFiles (instance: VideoPlaylistModel) { - logger.info('Removing files of video playlist %s.', instance.url) - - return instance.removeThumbnail() - } + @HasOne(() => ThumbnailModel, { + foreignKey: { + name: 'videoPlaylistId', + allowNull: true + }, + onDelete: 'CASCADE', + hooks: true + }) + Thumbnail: ThumbnailModel static listForApi (options: { followerActorId: number @@ -292,7 +303,8 @@ export class VideoPlaylistModel extends Model { } as AvailableForListOptions ] } as any, // FIXME: typings - ScopeNames.WITH_VIDEOS_LENGTH + ScopeNames.WITH_VIDEOS_LENGTH, + ScopeNames.WITH_THUMBNAIL ] return VideoPlaylistModel @@ -365,7 +377,7 @@ export class VideoPlaylistModel extends Model { } return VideoPlaylistModel - .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH ]) + .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ]) .findOne(query) } @@ -378,7 +390,7 @@ export class VideoPlaylistModel extends Model { } return VideoPlaylistModel - .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ]) + .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ]) .findOne(query) } @@ -389,7 +401,7 @@ export class VideoPlaylistModel extends Model { } } - return VideoPlaylistModel.scope(ScopeNames.WITH_ACCOUNT).findOne(query) + return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query) } static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { @@ -411,24 +423,34 @@ export class VideoPlaylistModel extends Model { return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query) } - getThumbnailName () { + setThumbnail (thumbnail: ThumbnailModel) { + this.Thumbnail = thumbnail + } + + getThumbnail () { + return this.Thumbnail + } + + hasThumbnail () { + return !!this.Thumbnail + } + + generateThumbnailName () { const extension = '.jpg' return 'playlist-' + this.uuid + extension } getThumbnailUrl () { - return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() + if (!this.hasThumbnail()) return null + + return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnail().filename } getThumbnailStaticPath () { - return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) - } + if (!this.hasThumbnail()) return null - removeThumbnail () { - const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) - return remove(thumbnailPath) - .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err })) + return join(STATIC_PATHS.THUMBNAILS, this.getThumbnail().filename) } setAsRefreshed () { @@ -482,6 +504,17 @@ export class VideoPlaylistModel extends Model { return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t) } + let icon: ActivityIconObject + if (this.hasThumbnail()) { + icon = { + type: 'Image' as 'Image', + url: this.getThumbnailUrl(), + mediaType: 'image/jpeg' as 'image/jpeg', + width: THUMBNAILS_SIZE.width, + height: THUMBNAILS_SIZE.height + } + } + return activityPubCollectionPagination(this.url, handler, page) .then(o => { return Object.assign(o, { @@ -492,13 +525,7 @@ export class VideoPlaylistModel extends Model { published: this.createdAt.toISOString(), updated: this.updatedAt.toISOString(), attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [], - icon: { - type: 'Image' as 'Image', - url: this.getThumbnailUrl(), - mediaType: 'image/jpeg' as 'image/jpeg', - width: THUMBNAILS_SIZE.width, - height: THUMBNAILS_SIZE.height - } + icon }) }) } diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 38447797e..9840d17fd 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -107,6 +107,8 @@ import { VideoImportModel } from './video-import' import { VideoStreamingPlaylistModel } from './video-streaming-playlist' import { VideoPlaylistElementModel } from './video-playlist-element' import { CONFIG } from '../../initializers/config' +import { ThumbnailModel } from './thumbnail' +import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation const indexes: Sequelize.DefineIndexesOptions[] = [ @@ -181,7 +183,8 @@ export enum ScopeNames { WITH_BLACKLISTED = 'WITH_BLACKLISTED', WITH_USER_HISTORY = 'WITH_USER_HISTORY', WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', - WITH_USER_ID = 'WITH_USER_ID' + WITH_USER_ID = 'WITH_USER_ID', + WITH_THUMBNAILS = 'WITH_THUMBNAILS' } type ForAPIOptions = { @@ -473,6 +476,14 @@ type AvailableForListIDsOptions = { return query }, + [ ScopeNames.WITH_THUMBNAILS ]: { + include: [ + { + model: () => ThumbnailModel, + required: false + } + ] + }, [ ScopeNames.WITH_USER_ID ]: { include: [ { @@ -771,6 +782,16 @@ export class VideoModel extends Model { }) Tags: TagModel[] + @HasMany(() => ThumbnailModel, { + foreignKey: { + name: 'videoId', + allowNull: true + }, + hooks: true, + onDelete: 'cascade' + }) + Thumbnails: ThumbnailModel[] + @HasMany(() => VideoPlaylistElementModel, { foreignKey: { name: 'videoId', @@ -920,15 +941,11 @@ export class VideoModel extends Model { logger.info('Removing files of video %s.', instance.url) - tasks.push(instance.removeThumbnail()) - if (instance.isOwned()) { if (!Array.isArray(instance.VideoFiles)) { instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[] } - tasks.push(instance.removePreview()) - // Remove physical files and torrents instance.VideoFiles.forEach(file => { tasks.push(instance.removeFile(file)) @@ -955,7 +972,11 @@ export class VideoModel extends Model { } } - return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]).findAll(query) + return VideoModel.scope([ + ScopeNames.WITH_FILES, + ScopeNames.WITH_STREAMING_PLAYLISTS, + ScopeNames.WITH_THUMBNAILS + ]).findAll(query) } static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { @@ -1048,7 +1069,7 @@ export class VideoModel extends Model { return Bluebird.all([ // FIXME: typing issue - VideoModel.findAll(query as any), + VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query as any), VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT }) ]).then(([ rows, totals ]) => { // totals: totalVideos + totalVideoShares @@ -1102,12 +1123,14 @@ export class VideoModel extends Model { }) } - return VideoModel.findAndCountAll(query).then(({ rows, count }) => { - return { - data: rows, - total: count - } - }) + return VideoModel.scope(ScopeNames.WITH_THUMBNAILS) + .findAndCountAll(query) + .then(({ rows, count }) => { + return { + data: rows, + total: count + } + }) } static async listForApi (options: { @@ -1296,7 +1319,7 @@ export class VideoModel extends Model { transaction: t } - return VideoModel.findOne(options) + return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options) } static loadWithRights (id: number | string, t?: Sequelize.Transaction) { @@ -1306,7 +1329,11 @@ export class VideoModel extends Model { transaction: t } - return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_USER_ID ]).findOne(options) + return VideoModel.scope([ + ScopeNames.WITH_BLACKLISTED, + ScopeNames.WITH_USER_ID, + ScopeNames.WITH_THUMBNAILS + ]).findOne(options) } static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { @@ -1318,12 +1345,15 @@ export class VideoModel extends Model { transaction: t } - return VideoModel.findOne(options) + return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options) } static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) { - return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]) - .findByPk(id, { transaction: t, logging }) + return VideoModel.scope([ + ScopeNames.WITH_FILES, + ScopeNames.WITH_STREAMING_PLAYLISTS, + ScopeNames.WITH_THUMBNAILS + ]).findByPk(id, { transaction: t, logging }) } static loadByUUIDWithFile (uuid: string) { @@ -1333,7 +1363,7 @@ export class VideoModel extends Model { } } - return VideoModel.findOne(options) + return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options) } static loadByUrl (url: string, transaction?: Sequelize.Transaction) { @@ -1344,7 +1374,7 @@ export class VideoModel extends Model { transaction } - return VideoModel.findOne(query) + return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query) } static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) { @@ -1358,7 +1388,8 @@ export class VideoModel extends Model { return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES, - ScopeNames.WITH_STREAMING_PLAYLISTS + ScopeNames.WITH_STREAMING_PLAYLISTS, + ScopeNames.WITH_THUMBNAILS ]).findOne(query) } @@ -1377,7 +1408,8 @@ export class VideoModel extends Model { ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE, ScopeNames.WITH_FILES, - ScopeNames.WITH_STREAMING_PLAYLISTS + ScopeNames.WITH_STREAMING_PLAYLISTS, + ScopeNames.WITH_THUMBNAILS ] if (userId) { @@ -1403,6 +1435,7 @@ export class VideoModel extends Model { ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE, + ScopeNames.WITH_THUMBNAILS, { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings ] @@ -1555,7 +1588,7 @@ export class VideoModel extends Model { } // FIXME: typing - const apiScope: any[] = [] + const apiScope: any[] = [ ScopeNames.WITH_THUMBNAILS ] if (options.user) { apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] }) @@ -1611,18 +1644,37 @@ export class VideoModel extends Model { return maxBy(this.VideoFiles, file => file.resolution) } + addThumbnail (thumbnail: ThumbnailModel) { + if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = [] + + // Already have this thumbnail, skip + if (this.Thumbnails.find(t => t.id === thumbnail.id)) return + + this.Thumbnails.push(thumbnail) + } + getVideoFilename (videoFile: VideoFileModel) { return this.uuid + '-' + videoFile.resolution + videoFile.extname } - getThumbnailName () { - const extension = '.jpg' - return this.uuid + extension + generateThumbnailName () { + return this.uuid + '.jpg' } - getPreviewName () { - const extension = '.jpg' - return this.uuid + extension + getThumbnail () { + if (Array.isArray(this.Thumbnails) === false) return undefined + + return this.Thumbnails.find(t => t.type === ThumbnailType.THUMBNAIL) + } + + generatePreviewName () { + return this.uuid + '.jpg' + } + + getPreview () { + if (Array.isArray(this.Thumbnails) === false) return undefined + + return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW) } getTorrentFileName (videoFile: VideoFileModel) { @@ -1634,24 +1686,6 @@ export class VideoModel extends Model { return this.remote === false } - createPreview (videoFile: VideoFileModel) { - return generateImageFromVideoFile( - this.getVideoFilePath(videoFile), - CONFIG.STORAGE.PREVIEWS_DIR, - this.getPreviewName(), - PREVIEWS_SIZE - ) - } - - createThumbnail (videoFile: VideoFileModel) { - return generateImageFromVideoFile( - this.getVideoFilePath(videoFile), - CONFIG.STORAGE.THUMBNAILS_DIR, - this.getThumbnailName(), - THUMBNAILS_SIZE - ) - } - getTorrentFilePath (videoFile: VideoFileModel) { return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) } @@ -1692,11 +1726,18 @@ export class VideoModel extends Model { } getThumbnailStaticPath () { - return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) + const thumbnail = this.getThumbnail() + if (!thumbnail) return null + + return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename) } getPreviewStaticPath () { - return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) + const preview = this.getPreview() + if (!preview) return null + + // We use a local cache, so specify our cache endpoint instead of potential remote URL + return join(STATIC_PATHS.PREVIEWS, preview.filename) } toFormattedJSON (options?: VideoFormattingJSONOptions): Video { @@ -1732,18 +1773,6 @@ export class VideoModel extends Model { return `/api/${API_VERSION}/videos/${this.uuid}/description` } - removeThumbnail () { - const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) - return remove(thumbnailPath) - .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err })) - } - - removePreview () { - const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName()) - return remove(previewPath) - .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err })) - } - removeFile (videoFile: VideoFileModel, isRedundancy = false) { const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR @@ -1816,10 +1845,6 @@ export class VideoModel extends Model { return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] } - getThumbnailUrl (baseUrlHttp: string) { - return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() - } - getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) { return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile) } -- cgit v1.2.3