From 6740b6428be1c27e9ad728eede7c428d1e2e9f47 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 31 Oct 2022 11:45:08 +0100 Subject: Fix transcoding failure when importing a video --- server/lib/job-queue/handlers/video-import.ts | 226 +++++++++++-------- server/lib/sync-channel.ts | 2 +- server/lib/transcoding/transcoding.ts | 1 + server/lib/video-import.ts | 309 -------------------------- server/lib/video-pre-import.ts | 309 ++++++++++++++++++++++++++ 5 files changed, 441 insertions(+), 406 deletions(-) delete mode 100644 server/lib/video-import.ts create mode 100644 server/lib/video-pre-import.ts (limited to 'server/lib') diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index 9901b878c..99016f511 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -12,7 +12,8 @@ import { buildMoveToObjectStorageJob, buildOptimizeOrMergeAudioJob } from '@serv import { VideoPathManager } from '@server/lib/video-path-manager' import { buildNextVideoState } from '@server/lib/video-state' import { ThumbnailModel } from '@server/models/video/thumbnail' -import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import' +import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models' +import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import' import { getLowercaseExtension } from '@shared/core-utils' import { isAudioFile } from '@shared/extra-utils' import { @@ -36,7 +37,6 @@ import { sequelizeTypescript } from '../../../initializers/database' import { VideoModel } from '../../../models/video/video' import { VideoFileModel } from '../../../models/video/video-file' import { VideoImportModel } from '../../../models/video/video-import' -import { MThumbnail } from '../../../types/models/video/thumbnail' import { federateVideoIfNeeded } from '../../activitypub/videos' import { Notifier } from '../../notifier' import { generateVideoMiniature } from '../../thumbnail' @@ -178,125 +178,159 @@ async function processFile (downloader: () => Promise, videoImport: MVid } // Video is accepted, resuming preparation - const videoWithFiles = Object.assign(videoImport.Video, { VideoFiles: [ videoFile ], VideoStreamingPlaylists: [] }) - // To clean files if the import fails - const videoImportWithFiles: MVideoImportDefaultFiles = Object.assign(videoImport, { Video: videoWithFiles }) - - // Move file - const videoDestFile = VideoPathManager.Instance.getFSVideoFileOutputPath(videoImportWithFiles.Video, videoFile) - await move(tempVideoPath, videoDestFile) - tempVideoPath = null // This path is not used anymore - - // Generate miniature if the import did not created it - let thumbnailModel: MThumbnail - let thumbnailSave: object - if (!videoImportWithFiles.Video.getMiniature()) { - thumbnailModel = await generateVideoMiniature({ - video: videoImportWithFiles.Video, - videoFile, - type: ThumbnailType.MINIATURE - }) - thumbnailSave = thumbnailModel.toJSON() - } + const videoFileLockReleaser = await VideoPathManager.Instance.lockFiles(videoImport.Video.uuid) - // Generate preview if the import did not created it - let previewModel: MThumbnail - let previewSave: object - if (!videoImportWithFiles.Video.getPreview()) { - previewModel = await generateVideoMiniature({ - video: videoImportWithFiles.Video, - videoFile, - type: ThumbnailType.PREVIEW - }) - previewSave = previewModel.toJSON() - } + try { + const videoImportWithFiles = await refreshVideoImportFromDB(videoImport, videoFile) - // Create torrent - await createTorrentAndSetInfoHash(videoImportWithFiles.Video, videoFile) + // Move file + const videoDestFile = VideoPathManager.Instance.getFSVideoFileOutputPath(videoImportWithFiles.Video, videoFile) + await move(tempVideoPath, videoDestFile) - const videoFileSave = videoFile.toJSON() + tempVideoPath = null // This path is not used anymore - const { videoImportUpdated, video } = await retryTransactionWrapper(() => { - return sequelizeTypescript.transaction(async t => { - const videoImportToUpdate = videoImportWithFiles as MVideoImportVideo + let { + miniatureModel: thumbnailModel, + miniatureJSONSave: thumbnailSave + } = await generateMiniature(videoImportWithFiles, videoFile, ThumbnailType.MINIATURE) - // Refresh video - const video = await VideoModel.load(videoImportToUpdate.videoId, t) - if (!video) throw new Error('Video linked to import ' + videoImportToUpdate.videoId + ' does not exist anymore.') + let { + miniatureModel: previewModel, + miniatureJSONSave: previewSave + } = await generateMiniature(videoImportWithFiles, videoFile, ThumbnailType.PREVIEW) - const videoFileCreated = await videoFile.save({ transaction: t }) + // Create torrent + await createTorrentAndSetInfoHash(videoImportWithFiles.Video, videoFile) - // Update video DB object - video.duration = duration - video.state = buildNextVideoState(video.state) - await video.save({ transaction: t }) + const videoFileSave = videoFile.toJSON() - if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t) - if (previewModel) await video.addAndSaveThumbnail(previewModel, t) + const { videoImportUpdated, video } = await retryTransactionWrapper(() => { + return sequelizeTypescript.transaction(async t => { + // Refresh video + const video = await VideoModel.load(videoImportWithFiles.videoId, t) + if (!video) throw new Error('Video linked to import ' + videoImportWithFiles.videoId + ' does not exist anymore.') - // Now we can federate the video (reload from database, we need more attributes) - const videoForFederation = await VideoModel.loadFull(video.uuid, t) - await federateVideoIfNeeded(videoForFederation, true, t) + await videoFile.save({ transaction: t }) - // Update video import object - videoImportToUpdate.state = VideoImportState.SUCCESS - const videoImportUpdated = await videoImportToUpdate.save({ transaction: t }) as MVideoImportVideo - videoImportUpdated.Video = video + // Update video DB object + video.duration = duration + video.state = buildNextVideoState(video.state) + await video.save({ transaction: t }) - videoImportToUpdate.Video = Object.assign(video, { VideoFiles: [ videoFileCreated ] }) + if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t) + if (previewModel) await video.addAndSaveThumbnail(previewModel, t) - logger.info('Video %s imported.', video.uuid) + // Now we can federate the video (reload from database, we need more attributes) + const videoForFederation = await VideoModel.loadFull(video.uuid, t) + await federateVideoIfNeeded(videoForFederation, true, t) - return { videoImportUpdated, video: videoForFederation } - }).catch(err => { - // Reset fields - if (thumbnailModel) thumbnailModel = new ThumbnailModel(thumbnailSave) - if (previewModel) previewModel = new ThumbnailModel(previewSave) + // Update video import object + videoImportWithFiles.state = VideoImportState.SUCCESS + const videoImportUpdated = await videoImportWithFiles.save({ transaction: t }) as MVideoImport - videoFile = new VideoFileModel(videoFileSave) + logger.info('Video %s imported.', video.uuid) - throw err - }) - }) + return { videoImportUpdated, video: videoForFederation } + }).catch(err => { + // Reset fields + if (thumbnailModel) thumbnailModel = new ThumbnailModel(thumbnailSave) + if (previewModel) previewModel = new ThumbnailModel(previewSave) - Notifier.Instance.notifyOnFinishedVideoImport({ videoImport: videoImportUpdated, success: true }) + videoFile = new VideoFileModel(videoFileSave) - if (video.isBlacklisted()) { - const videoBlacklist = Object.assign(video.VideoBlacklist, { Video: video }) + throw err + }) + }) - Notifier.Instance.notifyOnVideoAutoBlacklist(videoBlacklist) - } else { - Notifier.Instance.notifyOnNewVideoIfNeeded(video) + await afterImportSuccess({ videoImport: videoImportUpdated, video, videoFile, user: videoImport.User }) + } finally { + videoFileLockReleaser() } + } catch (err) { + await onImportError(err, tempVideoPath, videoImport) - if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) { - await JobQueue.Instance.createJob( - await buildMoveToObjectStorageJob({ video: videoImportUpdated.Video, previousVideoState: VideoState.TO_IMPORT }) - ) - } + throw err + } +} - // Create transcoding jobs? - if (video.state === VideoState.TO_TRANSCODE) { - await JobQueue.Instance.createJob( - await buildOptimizeOrMergeAudioJob({ video: videoImportUpdated.Video, videoFile, user: videoImport.User }) - ) - } +async function refreshVideoImportFromDB (videoImport: MVideoImportDefault, videoFile: MVideoFile): Promise { + // Refresh video, privacy may have changed + const video = await videoImport.Video.reload() + const videoWithFiles = Object.assign(video, { VideoFiles: [ videoFile ], VideoStreamingPlaylists: [] }) - } catch (err) { - try { - if (tempVideoPath) await remove(tempVideoPath) - } catch (errUnlink) { - logger.warn('Cannot cleanup files after a video import error.', { err: errUnlink }) - } + return Object.assign(videoImport, { Video: videoWithFiles }) +} - videoImport.error = err.message - if (videoImport.state !== VideoImportState.REJECTED) { - videoImport.state = VideoImportState.FAILED +async function generateMiniature (videoImportWithFiles: MVideoImportDefaultFiles, videoFile: MVideoFile, thumbnailType: ThumbnailType) { + // Generate miniature if the import did not created it + const needsMiniature = thumbnailType === ThumbnailType.MINIATURE + ? !videoImportWithFiles.Video.getMiniature() + : !videoImportWithFiles.Video.getPreview() + + if (!needsMiniature) { + return { + miniatureModel: null, + miniatureJSONSave: null } - await videoImport.save() + } - Notifier.Instance.notifyOnFinishedVideoImport({ videoImport, success: false }) + const miniatureModel = await generateVideoMiniature({ + video: videoImportWithFiles.Video, + videoFile, + type: ThumbnailType.MINIATURE + }) + const miniatureJSONSave = miniatureModel.toJSON() - throw err + return { + miniatureModel, + miniatureJSONSave + } +} + +async function afterImportSuccess (options: { + videoImport: MVideoImport + video: MVideoFullLight + videoFile: MVideoFile + user: MUserId +}) { + const { video, videoFile, videoImport, user } = options + + Notifier.Instance.notifyOnFinishedVideoImport({ videoImport: Object.assign(videoImport, { Video: video }), success: true }) + + if (video.isBlacklisted()) { + const videoBlacklist = Object.assign(video.VideoBlacklist, { Video: video }) + + Notifier.Instance.notifyOnVideoAutoBlacklist(videoBlacklist) + } else { + Notifier.Instance.notifyOnNewVideoIfNeeded(video) } + + if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) { + await JobQueue.Instance.createJob( + await buildMoveToObjectStorageJob({ video, previousVideoState: VideoState.TO_IMPORT }) + ) + return + } + + if (video.state === VideoState.TO_TRANSCODE) { // Create transcoding jobs? + await JobQueue.Instance.createJob( + await buildOptimizeOrMergeAudioJob({ video, videoFile, user }) + ) + } +} + +async function onImportError (err: Error, tempVideoPath: string, videoImport: MVideoImportVideo) { + try { + if (tempVideoPath) await remove(tempVideoPath) + } catch (errUnlink) { + logger.warn('Cannot cleanup files after a video import error.', { err: errUnlink }) + } + + videoImport.error = err.message + if (videoImport.state !== VideoImportState.REJECTED) { + videoImport.state = VideoImportState.FAILED + } + await videoImport.save() + + Notifier.Instance.notifyOnFinishedVideoImport({ videoImport, success: false }) } diff --git a/server/lib/sync-channel.ts b/server/lib/sync-channel.ts index 35af91429..4d00d6163 100644 --- a/server/lib/sync-channel.ts +++ b/server/lib/sync-channel.ts @@ -1,7 +1,7 @@ import { logger } from '@server/helpers/logger' import { YoutubeDLWrapper } from '@server/helpers/youtube-dl' import { CONFIG } from '@server/initializers/config' -import { buildYoutubeDLImport } from '@server/lib/video-import' +import { buildYoutubeDLImport } from '@server/lib/video-pre-import' import { UserModel } from '@server/models/user/user' import { VideoImportModel } from '@server/models/video/video-import' import { MChannel, MChannelAccountDefault, MChannelSync } from '@server/types/models' diff --git a/server/lib/transcoding/transcoding.ts b/server/lib/transcoding/transcoding.ts index 736e96e65..d83c5419f 100644 --- a/server/lib/transcoding/transcoding.ts +++ b/server/lib/transcoding/transcoding.ts @@ -46,6 +46,7 @@ async function optimizeOriginalVideofile (options: { const transcodeDirectory = CONFIG.STORAGE.TMP_DIR const newExtname = '.mp4' + // Will be released by our transcodeVOD function once ffmpeg is ran const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) try { diff --git a/server/lib/video-import.ts b/server/lib/video-import.ts deleted file mode 100644 index 796079875..000000000 --- a/server/lib/video-import.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { remove } from 'fs-extra' -import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils' -import { isVTTFileValid } from '@server/helpers/custom-validators/video-captions' -import { isVideoFileExtnameValid } from '@server/helpers/custom-validators/videos' -import { isResolvingToUnicastOnly } from '@server/helpers/dns' -import { logger } from '@server/helpers/logger' -import { YoutubeDLInfo, YoutubeDLWrapper } from '@server/helpers/youtube-dl' -import { CONFIG } from '@server/initializers/config' -import { sequelizeTypescript } from '@server/initializers/database' -import { Hooks } from '@server/lib/plugins/hooks' -import { ServerConfigManager } from '@server/lib/server-config-manager' -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 { VideoImportModel } from '@server/models/video/video-import' -import { FilteredModelAttributes } from '@server/types' -import { - MChannelAccountDefault, - MChannelSync, - MThumbnail, - MUser, - MVideoAccountDefault, - MVideoCaption, - MVideoImportFormattable, - MVideoTag, - MVideoThumbnail, - MVideoWithBlacklistLight -} from '@server/types/models' -import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models' -import { getLocalVideoActivityPubUrl } from './activitypub/url' -import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail' - -class YoutubeDlImportError extends Error { - code: YoutubeDlImportError.CODE - cause?: Error // Property to remove once ES2022 is used - constructor ({ message, code }) { - super(message) - this.code = code - } - - static fromError (err: Error, code: YoutubeDlImportError.CODE, message?: string) { - const ytDlErr = new this({ message: message ?? err.message, code }) - ytDlErr.cause = err - ytDlErr.stack = err.stack // Useless once ES2022 is used - return ytDlErr - } -} - -namespace YoutubeDlImportError { - export enum CODE { - FETCH_ERROR, - NOT_ONLY_UNICAST_URL - } -} - -// --------------------------------------------------------------------------- - -async function insertFromImportIntoDB (parameters: { - video: MVideoThumbnail - thumbnailModel: MThumbnail - previewModel: MThumbnail - videoChannel: MChannelAccountDefault - tags: string[] - videoImportAttributes: FilteredModelAttributes - user: MUser -}): Promise { - const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters - - const videoImport = await sequelizeTypescript.transaction(async t => { - const sequelizeOptions = { transaction: t } - - // Save video object in database - const videoCreated = await video.save(sequelizeOptions) as (MVideoAccountDefault & MVideoWithBlacklistLight & MVideoTag) - videoCreated.VideoChannel = videoChannel - - if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) - if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) - - await autoBlacklistVideoIfNeeded({ - video: videoCreated, - user, - notify: false, - isRemote: false, - isNew: true, - transaction: t - }) - - await setVideoTags({ video: videoCreated, tags, transaction: t }) - - // Create video import object in database - const videoImport = await VideoImportModel.create( - Object.assign({ videoId: videoCreated.id }, videoImportAttributes), - sequelizeOptions - ) as MVideoImportFormattable - videoImport.Video = videoCreated - - return videoImport - }) - - return videoImport -} - -async function buildVideoFromImport ({ channelId, importData, importDataOverride, importType }: { - channelId: number - importData: YoutubeDLInfo - importDataOverride?: Partial - importType: 'url' | 'torrent' -}): Promise { - let videoData = { - name: importDataOverride?.name || importData.name || 'Unknown name', - remote: false, - category: importDataOverride?.category || importData.category, - licence: importDataOverride?.licence ?? importData.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE, - language: importDataOverride?.language || importData.language, - commentsEnabled: importDataOverride?.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED, - downloadEnabled: importDataOverride?.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED, - waitTranscoding: importDataOverride?.waitTranscoding ?? true, - state: VideoState.TO_IMPORT, - nsfw: importDataOverride?.nsfw || importData.nsfw || false, - description: importDataOverride?.description || importData.description, - support: importDataOverride?.support || null, - privacy: importDataOverride?.privacy || VideoPrivacy.PRIVATE, - duration: 0, // duration will be set by the import job - channelId, - originallyPublishedAt: importDataOverride?.originallyPublishedAt - ? new Date(importDataOverride?.originallyPublishedAt) - : importData.originallyPublishedAtWithoutTime - } - - videoData = await Hooks.wrapObject( - videoData, - importType === 'url' - ? 'filter:api.video.import-url.video-attribute.result' - : 'filter:api.video.import-torrent.video-attribute.result' - ) - - const video = new VideoModel(videoData) - video.url = getLocalVideoActivityPubUrl(video) - - return video -} - -async function buildYoutubeDLImport (options: { - targetUrl: string - channel: MChannelAccountDefault - user: MUser - channelSync?: MChannelSync - importDataOverride?: Partial - thumbnailFilePath?: string - previewFilePath?: string -}) { - const { targetUrl, channel, channelSync, importDataOverride, thumbnailFilePath, previewFilePath, user } = options - - const youtubeDL = new YoutubeDLWrapper( - targetUrl, - ServerConfigManager.Instance.getEnabledResolutions('vod'), - CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION - ) - - // Get video infos - let youtubeDLInfo: YoutubeDLInfo - try { - youtubeDLInfo = await youtubeDL.getInfoForDownload() - } catch (err) { - throw YoutubeDlImportError.fromError( - err, YoutubeDlImportError.CODE.FETCH_ERROR, `Cannot fetch information from import for URL ${targetUrl}` - ) - } - - if (!await hasUnicastURLsOnly(youtubeDLInfo)) { - throw new YoutubeDlImportError({ - message: 'Cannot use non unicast IP as targetUrl.', - code: YoutubeDlImportError.CODE.NOT_ONLY_UNICAST_URL - }) - } - - const video = await buildVideoFromImport({ - channelId: channel.id, - importData: youtubeDLInfo, - importDataOverride, - importType: 'url' - }) - - const thumbnailModel = await forgeThumbnail({ - inputPath: thumbnailFilePath, - downloadUrl: youtubeDLInfo.thumbnailUrl, - video, - type: ThumbnailType.MINIATURE - }) - - const previewModel = await forgeThumbnail({ - inputPath: previewFilePath, - downloadUrl: youtubeDLInfo.thumbnailUrl, - video, - type: ThumbnailType.PREVIEW - }) - - const videoImport = await insertFromImportIntoDB({ - video, - thumbnailModel, - previewModel, - videoChannel: channel, - tags: importDataOverride?.tags || youtubeDLInfo.tags, - user, - videoImportAttributes: { - targetUrl, - state: VideoImportState.PENDING, - userId: user.id, - videoChannelSyncId: channelSync?.id - } - }) - - // Get video subtitles - await processYoutubeSubtitles(youtubeDL, targetUrl, video.id) - - let fileExt = `.${youtubeDLInfo.ext}` - if (!isVideoFileExtnameValid(fileExt)) fileExt = '.mp4' - - const payload: VideoImportPayload = { - type: 'youtube-dl' as 'youtube-dl', - videoImportId: videoImport.id, - fileExt, - // If part of a sync process, there is a parent job that will aggregate children results - preventException: !!channelSync - } - - return { - videoImport, - job: { type: 'video-import' as 'video-import', payload } - } -} - -// --------------------------------------------------------------------------- - -export { - buildYoutubeDLImport, - YoutubeDlImportError, - insertFromImportIntoDB, - buildVideoFromImport -} - -// --------------------------------------------------------------------------- - -async function forgeThumbnail ({ inputPath, video, downloadUrl, type }: { - inputPath?: string - downloadUrl?: string - video: MVideoThumbnail - type: ThumbnailType -}): Promise { - if (inputPath) { - return updateVideoMiniatureFromExisting({ - inputPath, - video, - type, - automaticallyGenerated: false - }) - } else if (downloadUrl) { - try { - return await updateVideoMiniatureFromUrl({ downloadUrl, video, type }) - } catch (err) { - logger.warn('Cannot process thumbnail %s from youtubedl.', downloadUrl, { err }) - } - } - return null -} - -async function processYoutubeSubtitles (youtubeDL: YoutubeDLWrapper, targetUrl: string, videoId: number) { - try { - const subtitles = await youtubeDL.getSubtitles() - - logger.info('Will create %s subtitles from youtube import %s.', subtitles.length, targetUrl) - - for (const subtitle of subtitles) { - if (!await isVTTFileValid(subtitle.path)) { - await remove(subtitle.path) - continue - } - - const videoCaption = new VideoCaptionModel({ - videoId, - language: subtitle.language, - filename: VideoCaptionModel.generateCaptionName(subtitle.language) - }) as MVideoCaption - - // Move physical file - await moveAndProcessCaptionFile(subtitle, videoCaption) - - await sequelizeTypescript.transaction(async t => { - await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t) - }) - } - } catch (err) { - logger.warn('Cannot get video subtitles.', { err }) - } -} - -async function hasUnicastURLsOnly (youtubeDLInfo: YoutubeDLInfo) { - const hosts = youtubeDLInfo.urls.map(u => new URL(u).hostname) - const uniqHosts = new Set(hosts) - - for (const h of uniqHosts) { - if (await isResolvingToUnicastOnly(h) !== true) { - return false - } - } - - return true -} diff --git a/server/lib/video-pre-import.ts b/server/lib/video-pre-import.ts new file mode 100644 index 000000000..796079875 --- /dev/null +++ b/server/lib/video-pre-import.ts @@ -0,0 +1,309 @@ +import { remove } from 'fs-extra' +import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils' +import { isVTTFileValid } from '@server/helpers/custom-validators/video-captions' +import { isVideoFileExtnameValid } from '@server/helpers/custom-validators/videos' +import { isResolvingToUnicastOnly } from '@server/helpers/dns' +import { logger } from '@server/helpers/logger' +import { YoutubeDLInfo, YoutubeDLWrapper } from '@server/helpers/youtube-dl' +import { CONFIG } from '@server/initializers/config' +import { sequelizeTypescript } from '@server/initializers/database' +import { Hooks } from '@server/lib/plugins/hooks' +import { ServerConfigManager } from '@server/lib/server-config-manager' +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 { VideoImportModel } from '@server/models/video/video-import' +import { FilteredModelAttributes } from '@server/types' +import { + MChannelAccountDefault, + MChannelSync, + MThumbnail, + MUser, + MVideoAccountDefault, + MVideoCaption, + MVideoImportFormattable, + MVideoTag, + MVideoThumbnail, + MVideoWithBlacklistLight +} from '@server/types/models' +import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models' +import { getLocalVideoActivityPubUrl } from './activitypub/url' +import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail' + +class YoutubeDlImportError extends Error { + code: YoutubeDlImportError.CODE + cause?: Error // Property to remove once ES2022 is used + constructor ({ message, code }) { + super(message) + this.code = code + } + + static fromError (err: Error, code: YoutubeDlImportError.CODE, message?: string) { + const ytDlErr = new this({ message: message ?? err.message, code }) + ytDlErr.cause = err + ytDlErr.stack = err.stack // Useless once ES2022 is used + return ytDlErr + } +} + +namespace YoutubeDlImportError { + export enum CODE { + FETCH_ERROR, + NOT_ONLY_UNICAST_URL + } +} + +// --------------------------------------------------------------------------- + +async function insertFromImportIntoDB (parameters: { + video: MVideoThumbnail + thumbnailModel: MThumbnail + previewModel: MThumbnail + videoChannel: MChannelAccountDefault + tags: string[] + videoImportAttributes: FilteredModelAttributes + user: MUser +}): Promise { + const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters + + const videoImport = await sequelizeTypescript.transaction(async t => { + const sequelizeOptions = { transaction: t } + + // Save video object in database + const videoCreated = await video.save(sequelizeOptions) as (MVideoAccountDefault & MVideoWithBlacklistLight & MVideoTag) + videoCreated.VideoChannel = videoChannel + + if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) + if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) + + await autoBlacklistVideoIfNeeded({ + video: videoCreated, + user, + notify: false, + isRemote: false, + isNew: true, + transaction: t + }) + + await setVideoTags({ video: videoCreated, tags, transaction: t }) + + // Create video import object in database + const videoImport = await VideoImportModel.create( + Object.assign({ videoId: videoCreated.id }, videoImportAttributes), + sequelizeOptions + ) as MVideoImportFormattable + videoImport.Video = videoCreated + + return videoImport + }) + + return videoImport +} + +async function buildVideoFromImport ({ channelId, importData, importDataOverride, importType }: { + channelId: number + importData: YoutubeDLInfo + importDataOverride?: Partial + importType: 'url' | 'torrent' +}): Promise { + let videoData = { + name: importDataOverride?.name || importData.name || 'Unknown name', + remote: false, + category: importDataOverride?.category || importData.category, + licence: importDataOverride?.licence ?? importData.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE, + language: importDataOverride?.language || importData.language, + commentsEnabled: importDataOverride?.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED, + downloadEnabled: importDataOverride?.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED, + waitTranscoding: importDataOverride?.waitTranscoding ?? true, + state: VideoState.TO_IMPORT, + nsfw: importDataOverride?.nsfw || importData.nsfw || false, + description: importDataOverride?.description || importData.description, + support: importDataOverride?.support || null, + privacy: importDataOverride?.privacy || VideoPrivacy.PRIVATE, + duration: 0, // duration will be set by the import job + channelId, + originallyPublishedAt: importDataOverride?.originallyPublishedAt + ? new Date(importDataOverride?.originallyPublishedAt) + : importData.originallyPublishedAtWithoutTime + } + + videoData = await Hooks.wrapObject( + videoData, + importType === 'url' + ? 'filter:api.video.import-url.video-attribute.result' + : 'filter:api.video.import-torrent.video-attribute.result' + ) + + const video = new VideoModel(videoData) + video.url = getLocalVideoActivityPubUrl(video) + + return video +} + +async function buildYoutubeDLImport (options: { + targetUrl: string + channel: MChannelAccountDefault + user: MUser + channelSync?: MChannelSync + importDataOverride?: Partial + thumbnailFilePath?: string + previewFilePath?: string +}) { + const { targetUrl, channel, channelSync, importDataOverride, thumbnailFilePath, previewFilePath, user } = options + + const youtubeDL = new YoutubeDLWrapper( + targetUrl, + ServerConfigManager.Instance.getEnabledResolutions('vod'), + CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION + ) + + // Get video infos + let youtubeDLInfo: YoutubeDLInfo + try { + youtubeDLInfo = await youtubeDL.getInfoForDownload() + } catch (err) { + throw YoutubeDlImportError.fromError( + err, YoutubeDlImportError.CODE.FETCH_ERROR, `Cannot fetch information from import for URL ${targetUrl}` + ) + } + + if (!await hasUnicastURLsOnly(youtubeDLInfo)) { + throw new YoutubeDlImportError({ + message: 'Cannot use non unicast IP as targetUrl.', + code: YoutubeDlImportError.CODE.NOT_ONLY_UNICAST_URL + }) + } + + const video = await buildVideoFromImport({ + channelId: channel.id, + importData: youtubeDLInfo, + importDataOverride, + importType: 'url' + }) + + const thumbnailModel = await forgeThumbnail({ + inputPath: thumbnailFilePath, + downloadUrl: youtubeDLInfo.thumbnailUrl, + video, + type: ThumbnailType.MINIATURE + }) + + const previewModel = await forgeThumbnail({ + inputPath: previewFilePath, + downloadUrl: youtubeDLInfo.thumbnailUrl, + video, + type: ThumbnailType.PREVIEW + }) + + const videoImport = await insertFromImportIntoDB({ + video, + thumbnailModel, + previewModel, + videoChannel: channel, + tags: importDataOverride?.tags || youtubeDLInfo.tags, + user, + videoImportAttributes: { + targetUrl, + state: VideoImportState.PENDING, + userId: user.id, + videoChannelSyncId: channelSync?.id + } + }) + + // Get video subtitles + await processYoutubeSubtitles(youtubeDL, targetUrl, video.id) + + let fileExt = `.${youtubeDLInfo.ext}` + if (!isVideoFileExtnameValid(fileExt)) fileExt = '.mp4' + + const payload: VideoImportPayload = { + type: 'youtube-dl' as 'youtube-dl', + videoImportId: videoImport.id, + fileExt, + // If part of a sync process, there is a parent job that will aggregate children results + preventException: !!channelSync + } + + return { + videoImport, + job: { type: 'video-import' as 'video-import', payload } + } +} + +// --------------------------------------------------------------------------- + +export { + buildYoutubeDLImport, + YoutubeDlImportError, + insertFromImportIntoDB, + buildVideoFromImport +} + +// --------------------------------------------------------------------------- + +async function forgeThumbnail ({ inputPath, video, downloadUrl, type }: { + inputPath?: string + downloadUrl?: string + video: MVideoThumbnail + type: ThumbnailType +}): Promise { + if (inputPath) { + return updateVideoMiniatureFromExisting({ + inputPath, + video, + type, + automaticallyGenerated: false + }) + } else if (downloadUrl) { + try { + return await updateVideoMiniatureFromUrl({ downloadUrl, video, type }) + } catch (err) { + logger.warn('Cannot process thumbnail %s from youtubedl.', downloadUrl, { err }) + } + } + return null +} + +async function processYoutubeSubtitles (youtubeDL: YoutubeDLWrapper, targetUrl: string, videoId: number) { + try { + const subtitles = await youtubeDL.getSubtitles() + + logger.info('Will create %s subtitles from youtube import %s.', subtitles.length, targetUrl) + + for (const subtitle of subtitles) { + if (!await isVTTFileValid(subtitle.path)) { + await remove(subtitle.path) + continue + } + + const videoCaption = new VideoCaptionModel({ + videoId, + language: subtitle.language, + filename: VideoCaptionModel.generateCaptionName(subtitle.language) + }) as MVideoCaption + + // Move physical file + await moveAndProcessCaptionFile(subtitle, videoCaption) + + await sequelizeTypescript.transaction(async t => { + await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t) + }) + } + } catch (err) { + logger.warn('Cannot get video subtitles.', { err }) + } +} + +async function hasUnicastURLsOnly (youtubeDLInfo: YoutubeDLInfo) { + const hosts = youtubeDLInfo.urls.map(u => new URL(u).hostname) + const uniqHosts = new Set(hosts) + + for (const h of uniqHosts) { + if (await isResolvingToUnicastOnly(h) !== true) { + return false + } + } + + return true +} -- cgit v1.2.3