diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts index 37481d12f..fd7b165fb 100644 --- a/server/controllers/api/videos/captions.ts +++ b/server/controllers/api/videos/captions.ts @@ -66,7 +66,7 @@ async function addVideoCaption (req: express.Request, res: express.Response) { await moveAndProcessCaptionFile(videoCaptionPhysicalFile, videoCaption) await sequelizeTypescript.transaction(async t => { - await VideoCaptionModel.insertOrReplaceLanguage(video.id, req.params.captionLanguage, t) + await VideoCaptionModel.insertOrReplaceLanguage(video.id, req.params.captionLanguage, null, t) // Update video update await federateVideoIfNeeded(video, false, t) diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts index 239d8291d..9f9e8fba7 100644 --- a/server/helpers/activitypub.ts +++ b/server/helpers/activitypub.ts @@ -2,11 +2,11 @@ import * as Bluebird from 'bluebird' import validator from 'validator' import { ResultList } from '../../shared/models' import { Activity } from '../../shared/models/activitypub' -import { ACTIVITY_PUB } from '../initializers/constants' +import { ACTIVITY_PUB, REMOTE_SCHEME } from '../initializers/constants' import { signJsonLDObject } from './peertube-crypto' import { pageToStartAndCount } from './core-utils' import { parse } from 'url' -import { MActor } from '../typings/models' +import { MActor, MVideoAccountLight } from '../typings/models' function activityPubContextify (data: T) { return Object.assign(data, { @@ -167,6 +167,12 @@ function checkUrlsSameHost (url1: string, url2: string) { return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase() } +function buildRemoteVideoBaseUrl (video: MVideoAccountLight, path: string) { + const host = video.VideoChannel.Account.Actor.Server.host + + return REMOTE_SCHEME.HTTP + '://' + host + path +} + // --------------------------------------------------------------------------- export { @@ -174,5 +180,6 @@ export { getAPId, activityPubContextify, activityPubCollectionPagination, - buildSignedActivity + buildSignedActivity, + buildRemoteVideoBaseUrl } diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts index 7e8252aa4..519dc83d0 100644 --- a/server/helpers/core-utils.ts +++ b/server/helpers/core-utils.ts @@ -199,6 +199,8 @@ function sha1 (str: string | Buffer, encoding: HexBase64Latin1Encoding = 'hex') return createHash('sha1').update(str).digest(encoding) } + + function execShell (command: string, options?: ExecOptions) { return new Promise<{ err?: Error, stdout: string, stderr: string }>((res, rej) => { exec(command, options, (err, stdout, stderr) => { diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts index 224f03f4e..22b5e14a2 100644 --- a/server/helpers/custom-validators/activitypub/videos.ts +++ b/server/helpers/custom-validators/activitypub/videos.ts @@ -51,6 +51,10 @@ function sanitizeAndCheckVideoTorrentObject (video: any) { logger.debug('Video has invalid captions', { video }) return false } + if (!setValidRemoteIcon(video)) { + logger.debug('Video has invalid icons', { video }) + return false + } // Default attributes if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED @@ -73,7 +77,6 @@ function sanitizeAndCheckVideoTorrentObject (video: any) { isDateValid(video.updated) && (!video.originallyPublishedAt || isDateValid(video.originallyPublishedAt)) && (!video.content || isRemoteVideoContentValid(video.mediaType, video.content)) && - isRemoteVideoIconValid(video.icon) && video.url.length !== 0 && video.attributedTo.length !== 0 } @@ -132,6 +135,8 @@ function setValidRemoteCaptions (video: any) { if (Array.isArray(video.subtitleLanguage) === false) return false video.subtitleLanguage = video.subtitleLanguage.filter(caption => { + if (!isActivityPubUrlValid(caption.url)) caption.url = null + return isRemoteStringIdentifierValid(caption) }) @@ -150,12 +155,19 @@ function isRemoteVideoContentValid (mediaType: string, content: string) { return mediaType === 'text/markdown' && isVideoTruncatedDescriptionValid(content) } -function isRemoteVideoIconValid (icon: any) { - return icon.type === 'Image' && - isActivityPubUrlValid(icon.url) && - icon.mediaType === 'image/jpeg' && - validator.isInt(icon.width + '', { min: 0 }) && - validator.isInt(icon.height + '', { min: 0 }) +function setValidRemoteIcon (video: any) { + if (video.icon && !isArray(video.icon)) video.icon = [ video.icon ] + if (!video.icon) video.icon = [] + + video.icon = video.icon.filter(icon => { + return icon.type === 'Image' && + isActivityPubUrlValid(icon.url) && + icon.mediaType === 'image/jpeg' && + validator.isInt(icon.width + '', { min: 0 }) && + validator.isInt(icon.height + '', { min: 0 }) + }) + + return video.icon.length !== 0 } function setValidRemoteVideoUrls (video: any) { diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 64803b1db..3a9946bba 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 475 +const LAST_MIGRATION_VERSION = 480 // --------------------------------------------------------------------------- @@ -541,11 +541,13 @@ let STATIC_MAX_AGE = { // Videos thumbnail size const THUMBNAILS_SIZE = { width: 223, - height: 122 + height: 122, + minWidth: 150 } const PREVIEWS_SIZE = { width: 850, - height: 480 + height: 480, + minWidth: 400 } const AVATARS_SIZE = { width: 120, diff --git a/server/initializers/migrations/0480-caption-file-url.ts b/server/initializers/migrations/0480-caption-file-url.ts new file mode 100644 index 000000000..7d8a3d4b9 --- /dev/null +++ b/server/initializers/migrations/0480-caption-file-url.ts @@ -0,0 +1,27 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction, + queryInterface: Sequelize.QueryInterface, + sequelize: Sequelize.Sequelize, + db: any +}): Promise { + { + const data = { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null + } + + await utils.queryInterface.addColumn('videoCaption', 'fileUrl', data) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 7a9d5168b..6bc2258cc 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -6,7 +6,8 @@ import { ActivityHashTagObject, ActivityMagnetUrlObject, ActivityPlaylistSegmentHashesObject, - ActivityPlaylistUrlObject, ActivityTagObject, + ActivityPlaylistUrlObject, + ActivityTagObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState @@ -17,14 +18,14 @@ import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validat import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' import { deleteNonExistingModels, resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils' import { logger } from '../../helpers/logger' -import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests' +import { doRequest } from '../../helpers/requests' import { ACTIVITY_PUB, MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, REMOTE_SCHEME, - STATIC_PATHS + STATIC_PATHS, THUMBNAILS_SIZE } from '../../initializers/constants' import { TagModel } from '../../models/video/tag' import { VideoModel } from '../../models/video/video' @@ -40,7 +41,7 @@ import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub import { createRates } from './video-rates' import { addVideoShares, shareVideoByServerAndChannel } from './share' import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video' -import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub' +import { buildRemoteVideoBaseUrl, checkUrlsSameHost, getAPId } from '../../helpers/activitypub' import { Notifier } from '../notifier' import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist' import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' @@ -71,6 +72,7 @@ import { MVideoThumbnail } from '../../typings/models' import { MThumbnail } from '../../typings/models/video/thumbnail' +import { maxBy, minBy } from 'lodash' async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVideo: boolean, transaction?: sequelize.Transaction) { const video = videoArg as MVideoAP @@ -131,19 +133,6 @@ async function fetchRemoteVideoDescription (video: MVideoAccountLight) { return body.description ? body.description : '' } -function fetchRemoteVideoStaticFile (video: MVideoAccountLight, path: string, destPath: string) { - const url = buildRemoteBaseUrl(video, path) - - // We need to provide a callback, if no we could have an uncaught exception - return doRequestAndSaveToFile({ uri: url }, destPath) -} - -function buildRemoteBaseUrl (video: MVideoAccountLight, path: string) { - const host = video.VideoChannel.Account.Actor.Server.host - - return REMOTE_SCHEME.HTTP + '://' + host + path -} - function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) { const channel = videoObject.attributedTo.find(a => a.type === 'Group') if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url) @@ -173,7 +162,7 @@ async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoTo const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'like' as 'like', crawlStartDate) await crawlCollectionPage(fetchedVideo.likes, handler, cleaner) - .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err })) + .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err, rootUrl: fetchedVideo.likes })) } else { jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' }) } @@ -183,7 +172,7 @@ async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoTo const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'dislike' as 'dislike', crawlStartDate) await crawlCollectionPage(fetchedVideo.dislikes, handler, cleaner) - .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err })) + .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err, rootUrl: fetchedVideo.dislikes })) } else { jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' }) } @@ -193,7 +182,7 @@ async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoTo const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate) await crawlCollectionPage(fetchedVideo.shares, handler, cleaner) - .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err })) + .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err, rootUrl: fetchedVideo.shares })) } else { jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' }) } @@ -203,7 +192,7 @@ async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoTo const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate) await crawlCollectionPage(fetchedVideo.comments, handler, cleaner) - .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err })) + .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err, rootUrl: fetchedVideo.comments })) } else { jobPayloads.push({ uri: fetchedVideo.comments, videoId: video.id, type: 'video-comments' as 'video-comments' }) } @@ -284,7 +273,7 @@ async function updateVideoFromAP (options: { let thumbnailModel: MThumbnail try { - thumbnailModel = await createVideoMiniatureFromUrl(videoObject.icon.url, video, ThumbnailType.MINIATURE) + thumbnailModel = await createVideoMiniatureFromUrl(getThumbnailFromIcons(videoObject).url, video, ThumbnailType.MINIATURE) } catch (err) { logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }) } @@ -327,8 +316,7 @@ async function updateVideoFromAP (options: { if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel, t) - // FIXME: use icon URL instead - const previewUrl = buildRemoteBaseUrl(videoUpdated, join(STATIC_PATHS.PREVIEWS, videoUpdated.getPreview().filename)) + const previewUrl = videoUpdated.getPreview().getFileUrl(videoUpdated) const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE) await videoUpdated.addAndSaveThumbnail(previewModel, t) @@ -391,7 +379,7 @@ async function updateVideoFromAP (options: { await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoUpdated.id, t) const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => { - return VideoCaptionModel.insertOrReplaceLanguage(videoUpdated.id, c.identifier, t) + return VideoCaptionModel.insertOrReplaceLanguage(videoUpdated.id, c.identifier, c.url, t) }) await Promise.all(videoCaptionsPromises) } @@ -483,7 +471,6 @@ export { federateVideoIfNeeded, fetchRemoteVideo, getOrCreateVideoAndAccountAndChannel, - fetchRemoteVideoStaticFile, fetchRemoteVideoDescription, getOrCreateVideoChannelFromVideoObject } @@ -519,7 +506,7 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, videoObject.to) const video = VideoModel.build(videoData) as MVideoThumbnail - const promiseThumbnail = createVideoMiniatureFromUrl(videoObject.icon.url, video, ThumbnailType.MINIATURE) + const promiseThumbnail = createVideoMiniatureFromUrl(getThumbnailFromIcons(videoObject).url, video, ThumbnailType.MINIATURE) let thumbnailModel: MThumbnail if (waitThumbnail === true) { @@ -534,9 +521,12 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) - // FIXME: use icon URL instead - const previewUrl = buildRemoteBaseUrl(videoCreated, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName())) - const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE) + const previewIcon = getPreviewFromIcons(videoObject) + const previewUrl = previewIcon + ? previewIcon.url + : buildRemoteVideoBaseUrl(videoCreated, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName())) + const previewModel = createPlaceholderThumbnail(previewUrl, videoCreated, ThumbnailType.PREVIEW, PREVIEWS_SIZE) + if (thumbnailModel) await videoCreated.addAndSaveThumbnail(previewModel, t) // Process files @@ -567,7 +557,7 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc // Process captions const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => { - return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t) + return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, c.url, t) }) await Promise.all(videoCaptionsPromises) @@ -721,3 +711,19 @@ function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObjec return attributes } + +function getThumbnailFromIcons (videoObject: VideoTorrentObject) { + let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth) + // Fallback if there are not valid icons + if (validIcons.length === 0) validIcons = videoObject.icon + + return minBy(validIcons, 'width') +} + +function getPreviewFromIcons (videoObject: VideoTorrentObject) { + 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') +} diff --git a/server/lib/files-cache/videos-caption-cache.ts b/server/lib/files-cache/videos-caption-cache.ts index 440c3fde8..26ab3bd0d 100644 --- a/server/lib/files-cache/videos-caption-cache.ts +++ b/server/lib/files-cache/videos-caption-cache.ts @@ -5,7 +5,7 @@ import { VideoCaptionModel } from '../../models/video/video-caption' import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' import { CONFIG } from '../../initializers/config' import { logger } from '../../helpers/logger' -import { fetchRemoteVideoStaticFile } from '../activitypub' +import { doRequestAndSaveToFile } from '@server/helpers/requests' type GetPathParam = { videoId: string, language: string } @@ -46,11 +46,10 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache { const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) if (!video) return undefined - // FIXME: use URL - const remoteStaticPath = videoCaption.getCaptionStaticPath() + const remoteUrl = videoCaption.getFileUrl(video) const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.getCaptionName()) - await fetchRemoteVideoStaticFile(video, remoteStaticPath, destPath) + await doRequestAndSaveToFile({ uri: remoteUrl }, destPath) return { isOwned: false, path: destPath } } diff --git a/server/lib/files-cache/videos-preview-cache.ts b/server/lib/files-cache/videos-preview-cache.ts index 3da6bb138..7bfeb5783 100644 --- a/server/lib/files-cache/videos-preview-cache.ts +++ b/server/lib/files-cache/videos-preview-cache.ts @@ -2,8 +2,8 @@ import { join } from 'path' import { FILES_CACHE, STATIC_PATHS } from '../../initializers/constants' import { VideoModel } from '../../models/video/video' import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' -import { CONFIG } from '../../initializers/config' -import { fetchRemoteVideoStaticFile } from '../activitypub' +import { doRequestAndSaveToFile } from '@server/helpers/requests' +import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' class VideosPreviewCache extends AbstractVideoStaticFileCache { @@ -32,11 +32,11 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache { if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.') - // FIXME: use URL - const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreview().filename) - const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, video.getPreview().filename) + const preview = video.getPreview() + const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename) - await fetchRemoteVideoStaticFile(video, remoteStaticPath, destPath) + const remoteUrl = preview.getFileUrl(video) + await doRequestAndSaveToFile({ uri: remoteUrl }, destPath) return { isOwned: false, path: destPath } } diff --git a/server/lib/job-queue/handlers/video-views.ts b/server/lib/job-queue/handlers/video-views.ts index 73fa5ed04..2258cd029 100644 --- a/server/lib/job-queue/handlers/video-views.ts +++ b/server/lib/job-queue/handlers/video-views.ts @@ -23,6 +23,8 @@ async function processVideosViews () { for (const videoId of videoIds) { try { const views = await Redis.Instance.getVideoViews(videoId, hour) + await Redis.Instance.deleteVideoViews(videoId, hour) + if (views) { logger.debug('Adding %d views to video %d in hour %d.', views, videoId, hour) @@ -52,8 +54,6 @@ async function processVideosViews () { logger.error('Cannot create video views for video %d in hour %d.', videoId, hour, { err }) } } - - await Redis.Instance.deleteVideoViews(videoId, hour) } catch (err) { logger.error('Cannot update video views of video %d in hour %d.', videoId, hour, { err }) } diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index a1c623b25..61f07c487 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts @@ -136,7 +136,6 @@ class JobQueue { const filteredJobTypes = this.filterJobTypes(jobType) - // TODO: optimize for (const jobType of filteredJobTypes) { const queue = this.queues[ jobType ] if (queue === undefined) { diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts index 3b011b1d2..b69bc0872 100644 --- a/server/models/video/thumbnail.ts +++ b/server/models/video/thumbnail.ts @@ -19,6 +19,8 @@ import { CONFIG } from '../../initializers/config' import { VideoModel } from './video' import { VideoPlaylistModel } from './video-playlist' import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' +import { MVideoAccountLight } from '@server/typings/models' +import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' @Table({ tableName: 'thumbnail', @@ -126,11 +128,14 @@ export class ThumbnailModel extends Model { return videoUUID + '.jpg' } - getFileUrl (isLocal: boolean) { - if (isLocal === false) return this.fileUrl + getFileUrl (video: MVideoAccountLight) { + const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename - const staticPath = ThumbnailModel.types[this.type].staticPath - return WEBSERVER.URL + staticPath + this.filename + if (video.isOwned()) return WEBSERVER.URL + staticPath + if (this.fileUrl) return this.fileUrl + + // Fallback if we don't have a file URL + return buildRemoteVideoBaseUrl(video, staticPath) } getPath () { diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts index 6335d44e4..1307c27f1 100644 --- a/server/models/video/video-caption.ts +++ b/server/models/video/video-caption.ts @@ -4,7 +4,7 @@ import { BeforeDestroy, BelongsTo, Column, - CreatedAt, + CreatedAt, DataType, ForeignKey, Is, Model, @@ -16,13 +16,14 @@ import { buildWhereIdOrUUID, throwIfNotValid } from '../utils' import { VideoModel } from './video' import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' -import { LAZY_STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers/constants' +import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants' import { join } from 'path' import { logger } from '../../helpers/logger' import { remove } from 'fs-extra' import { CONFIG } from '../../initializers/config' import * as Bluebird from 'bluebird' -import { MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/typings/models' +import { MVideo, MVideoAccountLight, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/typings/models' +import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' export enum ScopeNames { WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE' @@ -64,6 +65,10 @@ export class VideoCaptionModel extends Model { @Column language: string + @AllowNull(true) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max)) + fileUrl: string + @ForeignKey(() => VideoModel) @Column videoId: number @@ -114,10 +119,11 @@ export class VideoCaptionModel extends Model { return VideoCaptionModel.findOne(query) } - static insertOrReplaceLanguage (videoId: number, language: string, transaction: Transaction) { + static insertOrReplaceLanguage (videoId: number, language: string, fileUrl: string, transaction: Transaction) { const values = { videoId, - language + language, + fileUrl } return VideoCaptionModel.upsert(values, { transaction, returning: true }) @@ -175,4 +181,14 @@ export class VideoCaptionModel extends Model { removeCaptionFile (this: MVideoCaptionFormattable) { return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName()) } + + getFileUrl (video: MVideoAccountLight) { + if (!this.Video) this.Video = video as VideoModel + + if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath() + if (this.fileUrl) return this.fileUrl + + // Fallback if we don't have a file URL + return buildRemoteVideoBaseUrl(video, this.getCaptionStaticPath()) + } } diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index 2aa5b8677..bb50edcaa 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts @@ -307,11 +307,12 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject { for (const caption of video.VideoCaptions) { subtitleLanguage.push({ identifier: caption.language, - name: VideoCaptionModel.getLanguageLabel(caption.language) + name: VideoCaptionModel.getLanguageLabel(caption.language), + url: caption.getFileUrl(video) }) } - const miniature = video.getMiniature() + const icons = [ video.getMiniature(), video.getPreview() ] return { type: 'Video' as 'Video', @@ -336,13 +337,13 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject { content: video.getTruncatedDescription(), support: video.support, subtitleLanguage, - icon: { + icon: icons.map(i => ({ type: 'Image', - url: miniature.getFileUrl(video.isOwned()), + url: i.getFileUrl(video), mediaType: 'image/jpeg', - width: miniature.width, - height: miniature.height - }, + width: i.width, + height: i.height + })), url, likes: getVideoLikesActivityPubUrl(video), dislikes: getVideoDislikesActivityPubUrl(video), diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 20e1f1c4a..1a924e6c9 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -1121,7 +1121,7 @@ export class VideoModel extends Model { }, include: [ { - attributes: [ 'language' ], + attributes: [ 'language', 'fileUrl' ], model: VideoCaptionModel.unscoped(), required: false }, diff --git a/server/typings/models/video/video-caption.ts b/server/typings/models/video/video-caption.ts index ffa56f544..eeddedb40 100644 --- a/server/typings/models/video/video-caption.ts +++ b/server/typings/models/video/video-caption.ts @@ -11,6 +11,7 @@ export type MVideoCaption = Omit // ############################################################################ export type MVideoCaptionLanguage = Pick +export type MVideoCaptionLanguageUrl = Pick export type MVideoCaptionVideo = MVideoCaption & Use<'Video', Pick> diff --git a/server/typings/models/video/video.ts b/server/typings/models/video/video.ts index bcc5e5028..82d76f40c 100644 --- a/server/typings/models/video/video.ts +++ b/server/typings/models/video/video.ts @@ -9,7 +9,7 @@ import { MChannelUserId } from './video-channels' import { MTag } from './tag' -import { MVideoCaptionLanguage } from './video-caption' +import { MVideoCaptionLanguage, MVideoCaptionLanguageUrl } from './video-caption' import { MStreamingPlaylistFiles, MStreamingPlaylistRedundancies, @@ -140,7 +140,7 @@ export type MVideoAP = MVideo & Use<'Tags', MTag[]> & Use<'VideoChannel', MChannelAccountLight> & Use<'VideoStreamingPlaylists', MStreamingPlaylistFiles[]> & - Use<'VideoCaptions', MVideoCaptionLanguage[]> & + Use<'VideoCaptions', MVideoCaptionLanguageUrl[]> & Use<'VideoBlacklist', MVideoBlacklistUnfederated> & Use<'VideoFiles', MVideoFileRedundanciesOpt[]> & Use<'Thumbnails', MThumbnail[]> diff --git a/shared/models/activitypub/objects/common-objects.ts b/shared/models/activitypub/objects/common-objects.ts index de1116ab3..bab3ce366 100644 --- a/shared/models/activitypub/objects/common-objects.ts +++ b/shared/models/activitypub/objects/common-objects.ts @@ -1,6 +1,7 @@ export interface ActivityIdentifierObject { identifier: string name: string + url?: string } export interface ActivityIconObject { diff --git a/shared/models/activitypub/objects/video-torrent-object.ts b/shared/models/activitypub/objects/video-torrent-object.ts index 239822bc4..cadd0ea49 100644 --- a/shared/models/activitypub/objects/video-torrent-object.ts +++ b/shared/models/activitypub/objects/video-torrent-object.ts @@ -30,7 +30,9 @@ export interface VideoTorrentObject { mediaType: 'text/markdown' content: string support: string - icon: ActivityIconObject + + icon: ActivityIconObject[] + url: ActivityUrlObject[] likes: string dislikes: string diff --git a/shared/models/users/user-role.ts b/shared/models/users/user-role.ts index 0b6554e51..ae3a0d983 100644 --- a/shared/models/users/user-role.ts +++ b/shared/models/users/user-role.ts @@ -7,15 +7,13 @@ export enum UserRole { USER = 2 } -// TODO: use UserRole for key once https://github.com/Microsoft/TypeScript/issues/13042 is fixed -export const USER_ROLE_LABELS: { [ id: number ]: string } = { +export const USER_ROLE_LABELS: { [ id in UserRole ]: string } = { [UserRole.USER]: 'User', [UserRole.MODERATOR]: 'Moderator', [UserRole.ADMINISTRATOR]: 'Administrator' } -// TODO: use UserRole for key once https://github.com/Microsoft/TypeScript/issues/13042 is fixed -const userRoleRights: { [ id: number ]: UserRight[] } = { +const userRoleRights: { [ id in UserRole ]: UserRight[] } = { [UserRole.ADMINISTRATOR]: [ UserRight.ALL ],