diff --git a/server/lib/activitypub/videos/fetch.ts b/server/lib/activitypub/videos/fetch.ts index fdcf4ee5c..5e7f8552b 100644 --- a/server/lib/activitypub/videos/fetch.ts +++ b/server/lib/activitypub/videos/fetch.ts @@ -1,20 +1,19 @@ -import { checkUrlsSameHost, getAPId } from "@server/helpers/activitypub" -import { sanitizeAndCheckVideoTorrentObject } from "@server/helpers/custom-validators/activitypub/videos" -import { retryTransactionWrapper } from "@server/helpers/database-utils" -import { logger } from "@server/helpers/logger" -import { doJSONRequest, PeerTubeRequestError } from "@server/helpers/requests" -import { fetchVideoByUrl, VideoFetchByUrlType } from "@server/helpers/video" -import { REMOTE_SCHEME } from "@server/initializers/constants" -import { ActorFollowScoreCache } from "@server/lib/files-cache" -import { JobQueue } from "@server/lib/job-queue" -import { VideoModel } from "@server/models/video/video" -import { MVideoAccountLight, MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from "@server/types/models" -import { HttpStatusCode } from "@shared/core-utils" -import { VideoObject } from "@shared/models" -import { getOrCreateActorAndServerAndModel } from "../actor" -import { SyncParam, syncVideoExternalAttributes } from "./shared" -import { createVideo } from "./shared/video-create" -import { APVideoUpdater } from "./update" +import { checkUrlsSameHost, getAPId } from '@server/helpers/activitypub' +import { sanitizeAndCheckVideoTorrentObject } from '@server/helpers/custom-validators/activitypub/videos' +import { retryTransactionWrapper } from '@server/helpers/database-utils' +import { logger } from '@server/helpers/logger' +import { doJSONRequest, PeerTubeRequestError } from '@server/helpers/requests' +import { fetchVideoByUrl, VideoFetchByUrlType } from '@server/helpers/video' +import { REMOTE_SCHEME } from '@server/initializers/constants' +import { ActorFollowScoreCache } from '@server/lib/files-cache' +import { JobQueue } from '@server/lib/job-queue' +import { VideoModel } from '@server/models/video/video' +import { MVideoAccountLight, MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models' +import { HttpStatusCode } from '@shared/core-utils' +import { VideoObject } from '@shared/models' +import { getOrCreateActorAndServerAndModel } from '../actor' +import { APVideoCreator, SyncParam, syncVideoExternalAttributes } from './shared' +import { APVideoUpdater } from './updater' async function fetchRemoteVideo (videoUrl: string): Promise<{ statusCode: number, videoObject: VideoObject }> { logger.info('Fetching remote video %s.', videoUrl) @@ -115,16 +114,17 @@ async function getOrCreateVideoAndAccountAndChannel ( return { video: videoFromDatabase, created: false } } - const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl) - if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl) + const { videoObject } = await fetchRemoteVideo(videoUrl) + if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl) - const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo) + const actor = await getOrCreateVideoChannelFromVideoObject(videoObject) const videoChannel = actor.VideoChannel try { - const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail) + const creator = new APVideoCreator({ videoObject, channel: videoChannel }) + const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(creator.create.bind(creator), syncParam.thumbnail) - await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam) + await syncVideoExternalAttributes(videoCreated, videoObject, syncParam) return { video: videoCreated, created: true, autoBlacklisted } } catch (err) { diff --git a/server/lib/activitypub/videos/index.ts b/server/lib/activitypub/videos/index.ts index 0e126c85a..b560acb76 100644 --- a/server/lib/activitypub/videos/index.ts +++ b/server/lib/activitypub/videos/index.ts @@ -1,3 +1,3 @@ export * from './federate' export * from './fetch' -export * from './update' +export * from './updater' diff --git a/server/lib/activitypub/videos/shared/abstract-builder.ts b/server/lib/activitypub/videos/shared/abstract-builder.ts new file mode 100644 index 000000000..9d5f37e5f --- /dev/null +++ b/server/lib/activitypub/videos/shared/abstract-builder.ts @@ -0,0 +1,142 @@ +import { Transaction } from 'sequelize/types' +import { deleteNonExistingModels } from '@server/helpers/database-utils' +import { logger } from '@server/helpers/logger' +import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '@server/lib/thumbnail' +import { setVideoTags } from '@server/lib/video' +import { VideoCaptionModel } from '@server/models/video/video-caption' +import { VideoFileModel } from '@server/models/video/video-file' +import { VideoLiveModel } from '@server/models/video/video-live' +import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' +import { MStreamingPlaylistFilesVideo, MThumbnail, MVideoCaption, MVideoFile, MVideoFullLight, MVideoThumbnail } from '@server/types/models' +import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType } from '@shared/models' +import { + getCaptionAttributesFromObject, + getFileAttributesFromUrl, + getLiveAttributesFromObject, + getPreviewFromIcons, + getStreamingPlaylistAttributesFromObject, + getTagsFromObject, + getThumbnailFromIcons +} from './object-to-model-attributes' +import { getTrackerUrls, setVideoTrackers } from './trackers' + +export abstract class APVideoAbstractBuilder { + protected abstract videoObject: VideoObject + + protected tryToGenerateThumbnail (video: MVideoThumbnail): Promise { + return createVideoMiniatureFromUrl({ + downloadUrl: getThumbnailFromIcons(this.videoObject).url, + video, + type: ThumbnailType.MINIATURE + }).catch(err => { + logger.warn('Cannot generate thumbnail of %s.', this.videoObject.id, { err }) + + return undefined + }) + } + + protected async setPreview (video: MVideoFullLight, t: Transaction) { + // Don't fetch the preview that could be big, create a placeholder instead + const previewIcon = getPreviewFromIcons(this.videoObject) + if (!previewIcon) return + + const previewModel = createPlaceholderThumbnail({ + fileUrl: previewIcon.url, + video, + type: ThumbnailType.PREVIEW, + size: previewIcon + }) + + await video.addAndSaveThumbnail(previewModel, t) + } + + protected async setTags (video: MVideoFullLight, t: Transaction) { + const tags = getTagsFromObject(this.videoObject) + await setVideoTags({ video, tags, transaction: t }) + } + + protected async setTrackers (video: MVideoFullLight, t: Transaction) { + const trackers = getTrackerUrls(this.videoObject, video) + await setVideoTrackers({ video, trackers, transaction: t }) + } + + protected async insertOrReplaceCaptions (video: MVideoFullLight, t: Transaction) { + const videoCaptionsPromises = getCaptionAttributesFromObject(video, this.videoObject) + .map(a => new VideoCaptionModel(a) as MVideoCaption) + .map(c => VideoCaptionModel.insertOrReplaceLanguage(c, t)) + + await Promise.all(videoCaptionsPromises) + } + + protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) { + const attributes = getLiveAttributesFromObject(video, this.videoObject) + const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true }) + + video.VideoLive = videoLive + } + + protected async setWebTorrentFiles (video: MVideoFullLight, t: Transaction) { + const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url) + const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a)) + + // Remove video files that do not exist anymore + const destroyTasks = deleteNonExistingModels(video.VideoFiles || [], newVideoFiles, t) + await Promise.all(destroyTasks) + + // Update or add other one + const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t)) + video.VideoFiles = await Promise.all(upsertTasks) + } + + protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) { + const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject, video.VideoFiles || []) + const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) + + // Remove video playlists that do not exist anymore + const destroyTasks = deleteNonExistingModels(video.VideoStreamingPlaylists || [], newStreamingPlaylists, t) + await Promise.all(destroyTasks) + + video.VideoStreamingPlaylists = [] + + for (const playlistAttributes of streamingPlaylistAttributes) { + + const streamingPlaylistModel = await this.insertOrReplaceStreamingPlaylist(playlistAttributes, t) + streamingPlaylistModel.Video = video + + await this.setStreamingPlaylistFiles(video, streamingPlaylistModel, playlistAttributes.tagAPObject, t) + + video.VideoStreamingPlaylists.push(streamingPlaylistModel) + } + } + + private async insertOrReplaceStreamingPlaylist (attributes: VideoStreamingPlaylistModel['_creationAttributes'], t: Transaction) { + const [ streamingPlaylist ] = await VideoStreamingPlaylistModel.upsert(attributes, { returning: true, transaction: t }) + + return streamingPlaylist as MStreamingPlaylistFilesVideo + } + + private getStreamingPlaylistFiles (video: MVideoFullLight, type: VideoStreamingPlaylistType) { + const playlist = video.VideoStreamingPlaylists.find(s => s.type === type) + if (!playlist) return [] + + return playlist.VideoFiles + } + + private async setStreamingPlaylistFiles ( + video: MVideoFullLight, + playlistModel: MStreamingPlaylistFilesVideo, + tagObjects: ActivityTagObject[], + t: Transaction + ) { + const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(video, playlistModel.type) + + const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a)) + + const destroyTasks = deleteNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles, t) + await Promise.all(destroyTasks) + + // Update or add other one + const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t)) + playlistModel.VideoFiles = await Promise.all(upsertTasks) + } +} diff --git a/server/lib/activitypub/videos/shared/creator.ts b/server/lib/activitypub/videos/shared/creator.ts new file mode 100644 index 000000000..4f2d79374 --- /dev/null +++ b/server/lib/activitypub/videos/shared/creator.ts @@ -0,0 +1,90 @@ + +import { logger } from '@server/helpers/logger' +import { sequelizeTypescript } from '@server/initializers/database' +import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist' +import { VideoModel } from '@server/models/video/video' +import { MChannelAccountLight, MThumbnail, MVideoFullLight, MVideoThumbnail } from '@server/types/models' +import { VideoObject } from '@shared/models' +import { APVideoAbstractBuilder } from './abstract-builder' +import { getVideoAttributesFromObject } from './object-to-model-attributes' + +export class APVideoCreator extends APVideoAbstractBuilder { + protected readonly videoObject: VideoObject + private readonly channel: MChannelAccountLight + + constructor (options: { + videoObject: VideoObject + channel: MChannelAccountLight + }) { + super() + + this.videoObject = options.videoObject + this.channel = options.channel + } + + async create (waitThumbnail = false) { + logger.debug('Adding remote video %s.', this.videoObject.id) + + const videoData = await getVideoAttributesFromObject(this.channel, this.videoObject, this.videoObject.to) + const video = VideoModel.build(videoData) 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 = this.channel + + if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, 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) + + // We added a video in this channel, set it as updated + await this.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) + + return { autoBlacklisted, videoCreated } + } catch (err) { + // FIXME: Use rollback hook when https://github.com/sequelize/sequelize/pull/13038 is released + // Remove thumbnail + 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() + }) + } + + return { autoBlacklisted, videoCreated } + } +} diff --git a/server/lib/activitypub/videos/shared/index.ts b/server/lib/activitypub/videos/shared/index.ts index 4d24fbc6a..208a43705 100644 --- a/server/lib/activitypub/videos/shared/index.ts +++ b/server/lib/activitypub/videos/shared/index.ts @@ -1,4 +1,5 @@ +export * from './abstract-builder' +export * from './creator' export * from './object-to-model-attributes' export * from './trackers' -export * from './video-create' export * from './video-sync-attributes' diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts index 8a8105500..85548428c 100644 --- a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts +++ b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts @@ -23,6 +23,7 @@ import { VideoPrivacy, VideoStreamingPlaylistType } from '@shared/models' +import { VideoCaptionModel } from '@server/models/video/video-caption' function getThumbnailFromIcons (videoObject: VideoObject) { let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth) @@ -44,7 +45,7 @@ function getTagsFromObject (videoObject: VideoObject) { .map(t => t.name) } -function videoFileActivityUrlToDBAttributes ( +function getFileAttributesFromUrl ( videoOrPlaylist: MVideo | MStreamingPlaylistVideo, urls: (ActivityTagObject | ActivityUrlObject)[] ) { @@ -109,7 +110,7 @@ function videoFileActivityUrlToDBAttributes ( return attributes } -function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObject: VideoObject, videoFiles: MVideoFile[]) { +function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject, videoFiles: MVideoFile[]) { const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[] if (playlistUrls.length === 0) return [] @@ -134,6 +135,7 @@ function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObjec p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files), p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION, videoId: video.id, + tagAPObject: playlistUrlObject.tag } @@ -143,7 +145,24 @@ function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObjec return attributes } -function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) { +function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject) { + return { + saveReplay: videoObject.liveSaveReplay, + permanentLive: videoObject.permanentLive, + videoId: video.id + } +} + +function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) { + return videoObject.subtitleLanguage.map(c => ({ + videoId: video.id, + filename: VideoCaptionModel.generateCaptionName(c.identifier), + language: c.identifier, + fileUrl: c.url + })) +} + +function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) { const privacy = to.includes(ACTIVITY_PUB.PUBLIC) ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED @@ -203,10 +222,13 @@ export { getTagsFromObject, - videoActivityObjectToDBAttributes, + getFileAttributesFromUrl, + getStreamingPlaylistAttributesFromObject, - videoFileActivityUrlToDBAttributes, - streamingPlaylistActivityUrlToDBAttributes + getLiveAttributesFromObject, + getCaptionAttributesFromObject, + + getVideoAttributesFromObject } // --------------------------------------------------------------------------- diff --git a/server/lib/activitypub/videos/shared/video-create.ts b/server/lib/activitypub/videos/shared/video-create.ts deleted file mode 100644 index 80cc2ab37..000000000 --- a/server/lib/activitypub/videos/shared/video-create.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { logger } from '@server/helpers/logger' -import { sequelizeTypescript } from '@server/initializers/database' -import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '@server/lib/thumbnail' -import { setVideoTags } from '@server/lib/video' -import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist' -import { VideoModel } from '@server/models/video/video' -import { VideoCaptionModel } from '@server/models/video/video-caption' -import { VideoFileModel } from '@server/models/video/video-file' -import { VideoLiveModel } from '@server/models/video/video-live' -import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' -import { - MChannelAccountLight, - MStreamingPlaylistFilesVideo, - MThumbnail, - MVideoCaption, - MVideoFullLight, - MVideoThumbnail -} from '@server/types/models' -import { ThumbnailType, VideoObject } from '@shared/models' -import { - getPreviewFromIcons, - getTagsFromObject, - getThumbnailFromIcons, - streamingPlaylistActivityUrlToDBAttributes, - videoActivityObjectToDBAttributes, - videoFileActivityUrlToDBAttributes -} from './object-to-model-attributes' -import { getTrackerUrls, setVideoTrackers } from './trackers' - -async function createVideo (videoObject: VideoObject, channel: MChannelAccountLight, waitThumbnail = false) { - logger.debug('Adding remote video %s.', videoObject.id) - - const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, videoObject.to) - const video = VideoModel.build(videoData) as MVideoThumbnail - - const promiseThumbnail = createVideoMiniatureFromUrl({ - downloadUrl: getThumbnailFromIcons(videoObject).url, - video, - type: ThumbnailType.MINIATURE - }).catch(err => { - logger.error('Cannot create miniature from url.', { err }) - return undefined - }) - - let thumbnailModel: MThumbnail - if (waitThumbnail === true) { - thumbnailModel = await promiseThumbnail - } - - const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => { - try { - const sequelizeOptions = { transaction: t } - - const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight - videoCreated.VideoChannel = channel - - if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) - - const previewIcon = getPreviewFromIcons(videoObject) - if (previewIcon) { - const previewModel = createPlaceholderThumbnail({ - fileUrl: previewIcon.url, - video: videoCreated, - type: ThumbnailType.PREVIEW, - size: previewIcon - }) - - await videoCreated.addAndSaveThumbnail(previewModel, t) - } - - // Process files - const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject.url) - - const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t })) - const videoFiles = await Promise.all(videoFilePromises) - - const streamingPlaylistsAttributes = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles) - videoCreated.VideoStreamingPlaylists = [] - - for (const playlistAttributes of streamingPlaylistsAttributes) { - const playlist = await VideoStreamingPlaylistModel.create(playlistAttributes, { transaction: t }) as MStreamingPlaylistFilesVideo - playlist.Video = videoCreated - - const playlistFiles = videoFileActivityUrlToDBAttributes(playlist, playlistAttributes.tagAPObject) - const videoFilePromises = playlistFiles.map(f => VideoFileModel.create(f, { transaction: t })) - playlist.VideoFiles = await Promise.all(videoFilePromises) - - videoCreated.VideoStreamingPlaylists.push(playlist) - } - - // Process tags - const tags = getTagsFromObject(videoObject) - await setVideoTags({ video: videoCreated, tags, transaction: t }) - - // Process captions - const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => { - const caption = new VideoCaptionModel({ - videoId: videoCreated.id, - filename: VideoCaptionModel.generateCaptionName(c.identifier), - language: c.identifier, - fileUrl: c.url - }) as MVideoCaption - - return VideoCaptionModel.insertOrReplaceLanguage(caption, t) - }) - await Promise.all(videoCaptionsPromises) - - // Process trackers - { - const trackers = getTrackerUrls(videoObject, videoCreated) - await setVideoTrackers({ video: videoCreated, trackers, transaction: t }) - } - - videoCreated.VideoFiles = videoFiles - - if (videoCreated.isLive) { - const videoLive = new VideoLiveModel({ - streamKey: null, - saveReplay: videoObject.liveSaveReplay, - permanentLive: videoObject.permanentLive, - videoId: videoCreated.id - }) - - videoCreated.VideoLive = await videoLive.save({ transaction: 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.', videoObject.uuid) - - return { autoBlacklisted, videoCreated } - } catch (err) { - // FIXME: Use rollback hook when https://github.com/sequelize/sequelize/pull/13038 is released - // Remove thumbnail - 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() - }) - } - - return { autoBlacklisted, videoCreated } -} - -export { - createVideo -} diff --git a/server/lib/activitypub/videos/update.ts b/server/lib/activitypub/videos/update.ts deleted file mode 100644 index 444b51628..000000000 --- a/server/lib/activitypub/videos/update.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { Transaction } from 'sequelize/types' -import { deleteNonExistingModels, resetSequelizeInstance } from '@server/helpers/database-utils' -import { logger } from '@server/helpers/logger' -import { sequelizeTypescript } from '@server/initializers/database' -import { Notifier } from '@server/lib/notifier' -import { PeerTubeSocket } from '@server/lib/peertube-socket' -import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '@server/lib/thumbnail' -import { setVideoTags } from '@server/lib/video' -import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist' -import { VideoCaptionModel } from '@server/models/video/video-caption' -import { VideoFileModel } from '@server/models/video/video-file' -import { VideoLiveModel } from '@server/models/video/video-live' -import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' -import { - MChannelAccountLight, - MChannelDefault, - MStreamingPlaylistFilesVideo, - MThumbnail, - MVideoAccountLightBlacklistAllFiles, - MVideoCaption, - MVideoFile, - MVideoFullLight -} from '@server/types/models' -import { ThumbnailType, VideoObject, VideoPrivacy } from '@shared/models' -import { - getPreviewFromIcons, - getTagsFromObject, - getThumbnailFromIcons, - getTrackerUrls, - setVideoTrackers, - streamingPlaylistActivityUrlToDBAttributes, - videoActivityObjectToDBAttributes, - videoFileActivityUrlToDBAttributes -} from './shared' - -export class APVideoUpdater { - private readonly video: MVideoAccountLightBlacklistAllFiles - private readonly videoObject: VideoObject - private readonly channel: MChannelDefault - private readonly overrideTo: string[] - - private readonly wasPrivateVideo: boolean - private readonly wasUnlistedVideo: boolean - - private readonly videoFieldsSave: any - - private readonly oldVideoChannel: MChannelAccountLight - - constructor (options: { - video: MVideoAccountLightBlacklistAllFiles - videoObject: VideoObject - channel: MChannelDefault - overrideTo?: string[] - }) { - this.video = options.video - this.videoObject = options.videoObject - this.channel = options.channel - this.overrideTo = options.overrideTo - - this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE - this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED - - this.oldVideoChannel = this.video.VideoChannel - - this.videoFieldsSave = this.video.toJSON() - } - - async update () { - logger.debug('Updating remote video "%s".', this.videoObject.uuid, { videoObject: this.videoObject, channel: this.channel }) - - try { - const thumbnailModel = await this.tryToGenerateThumbnail() - - const videoUpdated = await sequelizeTypescript.transaction(async t => { - this.checkChannelUpdateOrThrow() - - const videoUpdated = await this.updateVideo(t) - - await this.processIcons(videoUpdated, thumbnailModel, t) - await this.processWebTorrentFiles(videoUpdated, t) - await this.processStreamingPlaylists(videoUpdated, t) - await this.processTags(videoUpdated, t) - await this.processTrackers(videoUpdated, t) - await this.processCaptions(videoUpdated, t) - await this.processLive(videoUpdated, t) - - return videoUpdated - }) - - await autoBlacklistVideoIfNeeded({ - video: videoUpdated, - user: undefined, - isRemote: true, - isNew: false, - transaction: undefined - }) - - // Notify our users? - if (this.wasPrivateVideo || this.wasUnlistedVideo) { - Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) - } - - if (videoUpdated.isLive) { - PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated) - PeerTubeSocket.Instance.sendVideoViewsUpdate(videoUpdated) - } - - logger.info('Remote video with uuid %s updated', this.videoObject.uuid) - - return videoUpdated - } catch (err) { - this.catchUpdateError(err) - } - } - - private tryToGenerateThumbnail (): Promise { - return createVideoMiniatureFromUrl({ - downloadUrl: getThumbnailFromIcons(this.videoObject).url, - video: this.video, - type: ThumbnailType.MINIATURE - }).catch(err => { - logger.warn('Cannot generate thumbnail of %s.', this.videoObject.id, { err }) - - return undefined - }) - } - - // Check we can update the channel: we trust the remote server - private checkChannelUpdateOrThrow () { - if (!this.oldVideoChannel.Actor.serverId || !this.channel.Actor.serverId) { - throw new Error('Cannot check old channel/new channel validity because `serverId` is null') - } - - if (this.oldVideoChannel.Actor.serverId !== this.channel.Actor.serverId) { - throw new Error(`New channel ${this.channel.Actor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.url}`) - } - } - - private updateVideo (transaction: Transaction) { - const to = this.overrideTo || this.videoObject.to - const videoData = videoActivityObjectToDBAttributes(this.channel, this.videoObject, to) - this.video.name = videoData.name - this.video.uuid = videoData.uuid - this.video.url = videoData.url - this.video.category = videoData.category - this.video.licence = videoData.licence - this.video.language = videoData.language - this.video.description = videoData.description - this.video.support = videoData.support - this.video.nsfw = videoData.nsfw - this.video.commentsEnabled = videoData.commentsEnabled - this.video.downloadEnabled = videoData.downloadEnabled - this.video.waitTranscoding = videoData.waitTranscoding - this.video.state = videoData.state - this.video.duration = videoData.duration - this.video.createdAt = videoData.createdAt - this.video.publishedAt = videoData.publishedAt - this.video.originallyPublishedAt = videoData.originallyPublishedAt - this.video.privacy = videoData.privacy - this.video.channelId = videoData.channelId - this.video.views = videoData.views - this.video.isLive = videoData.isLive - - // Ensures we update the updated video attribute - this.video.changed('updatedAt', true) - - return this.video.save({ transaction }) as Promise - } - - private async processIcons (videoUpdated: MVideoFullLight, thumbnailModel: MThumbnail, t: Transaction) { - if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel, t) - - // Don't fetch the preview that could be big, create a placeholder instead - const previewIcon = getPreviewFromIcons(this.videoObject) - if (videoUpdated.getPreview() && previewIcon) { - const previewModel = createPlaceholderThumbnail({ - fileUrl: previewIcon.url, - video: videoUpdated, - type: ThumbnailType.PREVIEW, - size: previewIcon - }) - await videoUpdated.addAndSaveThumbnail(previewModel, t) - } - } - - private async processWebTorrentFiles (videoUpdated: MVideoFullLight, t: Transaction) { - const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoUpdated, this.videoObject.url) - const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a)) - - // Remove video files that do not exist anymore - const destroyTasks = deleteNonExistingModels(videoUpdated.VideoFiles, newVideoFiles, t) - await Promise.all(destroyTasks) - - // Update or add other one - const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t)) - videoUpdated.VideoFiles = await Promise.all(upsertTasks) - } - - private async processStreamingPlaylists (videoUpdated: MVideoFullLight, t: Transaction) { - const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(videoUpdated, this.videoObject, videoUpdated.VideoFiles) - const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) - - // Remove video playlists that do not exist anymore - const destroyTasks = deleteNonExistingModels(videoUpdated.VideoStreamingPlaylists, newStreamingPlaylists, t) - await Promise.all(destroyTasks) - - let oldStreamingPlaylistFiles: MVideoFile[] = [] - for (const videoStreamingPlaylist of videoUpdated.VideoStreamingPlaylists) { - oldStreamingPlaylistFiles = oldStreamingPlaylistFiles.concat(videoStreamingPlaylist.VideoFiles) - } - - videoUpdated.VideoStreamingPlaylists = [] - - for (const playlistAttributes of streamingPlaylistAttributes) { - const streamingPlaylistModel = await VideoStreamingPlaylistModel.upsert(playlistAttributes, { returning: true, transaction: t }) - .then(([ streamingPlaylist ]) => streamingPlaylist as MStreamingPlaylistFilesVideo) - streamingPlaylistModel.Video = videoUpdated - - const newVideoFiles: MVideoFile[] = videoFileActivityUrlToDBAttributes(streamingPlaylistModel, playlistAttributes.tagAPObject) - .map(a => new VideoFileModel(a)) - const destroyTasks = deleteNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles, t) - await Promise.all(destroyTasks) - - // Update or add other one - const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t)) - streamingPlaylistModel.VideoFiles = await Promise.all(upsertTasks) - - videoUpdated.VideoStreamingPlaylists.push(streamingPlaylistModel) - } - } - - private async processTags (videoUpdated: MVideoFullLight, t: Transaction) { - const tags = getTagsFromObject(this.videoObject) - await setVideoTags({ video: videoUpdated, tags, transaction: t }) - } - - private async processTrackers (videoUpdated: MVideoFullLight, t: Transaction) { - const trackers = getTrackerUrls(this.videoObject, videoUpdated) - await setVideoTrackers({ video: videoUpdated, trackers, transaction: t }) - } - - private async processCaptions (videoUpdated: MVideoFullLight, t: Transaction) { - // Update captions - await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoUpdated.id, t) - - const videoCaptionsPromises = this.videoObject.subtitleLanguage.map(c => { - const caption = new VideoCaptionModel({ - videoId: videoUpdated.id, - filename: VideoCaptionModel.generateCaptionName(c.identifier), - language: c.identifier, - fileUrl: c.url - }) as MVideoCaption - - return VideoCaptionModel.insertOrReplaceLanguage(caption, t) - }) - - await Promise.all(videoCaptionsPromises) - } - - private async processLive (videoUpdated: MVideoFullLight, t: Transaction) { - // Create or update existing live - if (this.video.isLive) { - const [ videoLive ] = await VideoLiveModel.upsert({ - saveReplay: this.videoObject.liveSaveReplay, - permanentLive: this.videoObject.permanentLive, - videoId: this.video.id - }, { transaction: t, returning: true }) - - videoUpdated.VideoLive = videoLive - return - } - - // Delete existing live if it exists - await VideoLiveModel.destroy({ - where: { - videoId: this.video.id - }, - transaction: t - }) - - videoUpdated.VideoLive = null - } - - private catchUpdateError (err: Error) { - if (this.video !== undefined && this.videoFieldsSave !== undefined) { - resetSequelizeInstance(this.video, this.videoFieldsSave) - } - - // This is just a debug because we will retry the insert - logger.debug('Cannot update the remote video.', { err }) - throw err - } -} diff --git a/server/lib/activitypub/videos/updater.ts b/server/lib/activitypub/videos/updater.ts new file mode 100644 index 000000000..4338d1e22 --- /dev/null +++ b/server/lib/activitypub/videos/updater.ts @@ -0,0 +1,170 @@ +import { Transaction } from 'sequelize/types' +import { resetSequelizeInstance } from '@server/helpers/database-utils' +import { logger } from '@server/helpers/logger' +import { sequelizeTypescript } from '@server/initializers/database' +import { Notifier } from '@server/lib/notifier' +import { PeerTubeSocket } from '@server/lib/peertube-socket' +import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist' +import { VideoCaptionModel } from '@server/models/video/video-caption' +import { VideoLiveModel } from '@server/models/video/video-live' +import { MChannelAccountLight, MChannelDefault, MVideoAccountLightBlacklistAllFiles, MVideoFullLight } from '@server/types/models' +import { VideoObject, VideoPrivacy } from '@shared/models' +import { APVideoAbstractBuilder, getVideoAttributesFromObject } from './shared' + +export class APVideoUpdater extends APVideoAbstractBuilder { + protected readonly videoObject: VideoObject + + private readonly video: MVideoAccountLightBlacklistAllFiles + private readonly channel: MChannelDefault + private readonly overrideTo: string[] + + private readonly wasPrivateVideo: boolean + private readonly wasUnlistedVideo: boolean + + private readonly videoFieldsSave: any + + private readonly oldVideoChannel: MChannelAccountLight + + constructor (options: { + video: MVideoAccountLightBlacklistAllFiles + videoObject: VideoObject + channel: MChannelDefault + overrideTo?: string[] + }) { + super() + + this.video = options.video + this.videoObject = options.videoObject + this.channel = options.channel + this.overrideTo = options.overrideTo + + this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE + this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED + + this.oldVideoChannel = this.video.VideoChannel + + this.videoFieldsSave = this.video.toJSON() + } + + async update () { + logger.debug('Updating remote video "%s".', this.videoObject.uuid, { videoObject: this.videoObject, channel: this.channel }) + + try { + const thumbnailModel = await this.tryToGenerateThumbnail(this.video) + + const videoUpdated = await sequelizeTypescript.transaction(async t => { + this.checkChannelUpdateOrThrow() + + const videoUpdated = await this.updateVideo(t) + + if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel, t) + + await this.setPreview(videoUpdated, t) + await this.setWebTorrentFiles(videoUpdated, t) + await this.setStreamingPlaylists(videoUpdated, t) + await this.setTags(videoUpdated, t) + await this.setTrackers(videoUpdated, t) + await this.setCaptions(videoUpdated, t) + await this.setOrDeleteLive(videoUpdated, t) + + return videoUpdated + }) + + await autoBlacklistVideoIfNeeded({ + video: videoUpdated, + user: undefined, + isRemote: true, + isNew: false, + transaction: undefined + }) + + // Notify our users? + if (this.wasPrivateVideo || this.wasUnlistedVideo) { + Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) + } + + if (videoUpdated.isLive) { + PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated) + PeerTubeSocket.Instance.sendVideoViewsUpdate(videoUpdated) + } + + logger.info('Remote video with uuid %s updated', this.videoObject.uuid) + + return videoUpdated + } catch (err) { + this.catchUpdateError(err) + } + } + + // Check we can update the channel: we trust the remote server + private checkChannelUpdateOrThrow () { + if (!this.oldVideoChannel.Actor.serverId || !this.channel.Actor.serverId) { + throw new Error('Cannot check old channel/new channel validity because `serverId` is null') + } + + if (this.oldVideoChannel.Actor.serverId !== this.channel.Actor.serverId) { + throw new Error(`New channel ${this.channel.Actor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.url}`) + } + } + + private updateVideo (transaction: Transaction) { + const to = this.overrideTo || this.videoObject.to + const videoData = getVideoAttributesFromObject(this.channel, this.videoObject, to) + this.video.name = videoData.name + this.video.uuid = videoData.uuid + this.video.url = videoData.url + this.video.category = videoData.category + this.video.licence = videoData.licence + this.video.language = videoData.language + this.video.description = videoData.description + this.video.support = videoData.support + this.video.nsfw = videoData.nsfw + this.video.commentsEnabled = videoData.commentsEnabled + this.video.downloadEnabled = videoData.downloadEnabled + this.video.waitTranscoding = videoData.waitTranscoding + this.video.state = videoData.state + this.video.duration = videoData.duration + this.video.createdAt = videoData.createdAt + this.video.publishedAt = videoData.publishedAt + this.video.originallyPublishedAt = videoData.originallyPublishedAt + this.video.privacy = videoData.privacy + this.video.channelId = videoData.channelId + this.video.views = videoData.views + this.video.isLive = videoData.isLive + + // Ensures we update the updated video attribute + this.video.changed('updatedAt', true) + + return this.video.save({ transaction }) as Promise + } + + private async setCaptions (videoUpdated: MVideoFullLight, t: Transaction) { + await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoUpdated.id, t) + + await this.insertOrReplaceCaptions(videoUpdated, t) + } + + private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction: Transaction) { + if (this.video.isLive) return this.insertOrReplaceLive(videoUpdated, transaction) + + // Delete existing live if it exists + await VideoLiveModel.destroy({ + where: { + videoId: this.video.id + }, + transaction + }) + + videoUpdated.VideoLive = null + } + + private catchUpdateError (err: Error) { + if (this.video !== undefined && this.videoFieldsSave !== undefined) { + resetSequelizeInstance(this.video, this.videoFieldsSave) + } + + // This is just a debug because we will retry the insert + logger.debug('Cannot update the remote video.', { err }) + throw err + } +} diff --git a/shared/extra-utils/videos/videos.ts b/shared/extra-utils/videos/videos.ts index e88256ac0..98a568a02 100644 --- a/shared/extra-utils/videos/videos.ts +++ b/shared/extra-utils/videos/videos.ts @@ -774,9 +774,11 @@ async function completeVideoCheck ( expect(torrent.files[0].path).to.exist.and.to.not.equal('') } + expect(videoDetails.thumbnailPath).to.exist await testImage(url, attributes.thumbnailfile || attributes.fixture, videoDetails.thumbnailPath) if (attributes.previewfile) { + expect(videoDetails.previewPath).to.exist await testImage(url, attributes.previewfile, videoDetails.previewPath) } }