Integrate transcription in PeerTube

This commit is contained in:
Chocobozzz
2024-06-13 09:23:12 +02:00
parent ef14cf4a5c
commit 1bfb791e05
172 changed files with 2674 additions and 945 deletions

View File

@@ -1,10 +1,16 @@
import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { MIMETYPES } from '@server/initializers/constants.js'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '@server/lib/local-actor.js'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
import { getServerActor } from '@server/models/application/application.js'
import { ModelCache } from '@server/models/shared/model-cache.js'
import express from 'express'
import { remove, writeJSON } from 'fs-extra/esm'
import snakeCase from 'lodash-es/snakeCase.js'
import validator from 'validator'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger.js'
import { CustomConfigAuditView, auditLoggerFactory, getAuditIdFromRes } from '../../helpers/audit-logger.js'
import { objectConverter } from '../../helpers/core-utils.js'
import { CONFIG, reloadConfig } from '../../initializers/config.js'
import { ClientHtml } from '../../lib/html/client-html.js'
@@ -18,12 +24,6 @@ import {
updateBannerValidator
} from '../../middlewares/index.js'
import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.js'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { MIMETYPES } from '@server/initializers/constants.js'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '@server/lib/local-actor.js'
import { getServerActor } from '@server/models/application/application.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
import { ModelCache } from '@server/models/shared/model-cache.js'
const configRouter = express.Router()
@@ -385,6 +385,12 @@ function customConfig (): CustomConfig {
enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
}
},
videoTranscription: {
enabled: CONFIG.VIDEO_TRANSCRIPTION.ENABLED,
remoteRunners: {
enabled: CONFIG.VIDEO_TRANSCRIPTION.REMOTE_RUNNERS.ENABLED
}
},
videoFile: {
update: {
enabled: CONFIG.VIDEO_FILE.UPDATE.ENABLED

View File

@@ -1,4 +1,25 @@
import express, { UploadFiles } from 'express'
import {
AbortRunnerJobBody,
AcceptRunnerJobResult,
ErrorRunnerJobBody,
HttpStatusCode,
ListRunnerJobsQuery,
LiveRTMPHLSTranscodingUpdatePayload,
RequestRunnerJobResult,
RunnerJobState,
RunnerJobSuccessBody,
RunnerJobSuccessPayload,
RunnerJobType,
RunnerJobUpdateBody,
RunnerJobUpdatePayload,
ServerErrorCode,
TranscriptionSuccess,
UserRight,
VODAudioMergeTranscodingSuccess,
VODHLSTranscodingSuccess,
VODWebVideoTranscodingSuccess,
VideoStudioTranscodingSuccess
} from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
@@ -28,33 +49,13 @@ import {
successRunnerJobValidator,
updateRunnerJobValidator
} from '@server/middlewares/validators/runners/index.js'
import { RunnerModel } from '@server/models/runner/runner.js'
import { RunnerJobModel } from '@server/models/runner/runner-job.js'
import {
AbortRunnerJobBody,
AcceptRunnerJobResult,
ErrorRunnerJobBody,
HttpStatusCode,
ListRunnerJobsQuery,
LiveRTMPHLSTranscodingUpdatePayload,
RequestRunnerJobResult,
RunnerJobState,
RunnerJobSuccessBody,
RunnerJobSuccessPayload,
RunnerJobType,
RunnerJobUpdateBody,
RunnerJobUpdatePayload,
ServerErrorCode,
UserRight,
VideoStudioTranscodingSuccess,
VODAudioMergeTranscodingSuccess,
VODHLSTranscodingSuccess,
VODWebVideoTranscodingSuccess
} from '@peertube/peertube-models'
import { RunnerModel } from '@server/models/runner/runner.js'
import express, { UploadFiles } from 'express'
const postRunnerJobSuccessVideoFiles = createReqFiles(
[ 'payload[videoFile]', 'payload[resolutionPlaylistFile]' ],
{ ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.M3U8.MIMETYPE_EXT }
[ 'payload[videoFile]', 'payload[resolutionPlaylistFile]', 'payload[vttFile]' ],
{ ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.M3U8.MIMETYPE_EXT, ...MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT }
)
const runnerJobUpdateVideoFiles = createReqFiles(
@@ -345,7 +346,15 @@ const jobSuccessPayloadBuilders: {
}
},
'live-rtmp-hls-transcoding': () => ({})
'live-rtmp-hls-transcoding': () => ({}),
'video-transcription': (payload: TranscriptionSuccess, files) => {
return {
...payload,
vttFile: files['payload[vttFile]'][0].path
}
}
}
async function postRunnerJobSuccess (req: express.Request, res: express.Response) {

View File

@@ -76,6 +76,7 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
abuseStateChange: body.abuseStateChange,
newPeerTubeVersion: body.newPeerTubeVersion,
newPluginVersion: body.newPluginVersion,
myVideoTranscriptionGenerated: body.myVideoTranscriptionGenerated,
myVideoStudioEditionFinished: body.myVideoStudioEditionFinished
}

View File

@@ -1,31 +1,47 @@
import express from 'express'
import { HttpStatusCode } from '@peertube/peertube-models'
import { HttpStatusCode, VideoCaptionGenerate } from '@peertube/peertube-models'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { createLocalCaption, createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import express from 'express'
import { createReqFiles } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { MIMETYPES } from '../../../initializers/constants.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { federateVideoIfNeeded } from '../../../lib/activitypub/videos/index.js'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js'
import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators/index.js'
import {
addVideoCaptionValidator,
deleteVideoCaptionValidator,
generateVideoCaptionValidator,
listVideoCaptionsValidator
} from '../../../middlewares/validators/index.js'
import { VideoCaptionModel } from '../../../models/video/video-caption.js'
import { createLocalCaption } from '@server/lib/video-captions.js'
const lTags = loggerTagsFactory('api', 'video-caption')
const reqVideoCaptionAdd = createReqFiles([ 'captionfile' ], MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
const videoCaptionsRouter = express.Router()
videoCaptionsRouter.post('/:videoId/captions/generate',
authenticate,
asyncMiddleware(generateVideoCaptionValidator),
asyncMiddleware(createGenerateVideoCaption)
)
videoCaptionsRouter.get('/:videoId/captions',
asyncMiddleware(listVideoCaptionsValidator),
asyncMiddleware(listVideoCaptions)
)
videoCaptionsRouter.put('/:videoId/captions/:captionLanguage',
authenticate,
reqVideoCaptionAdd,
asyncMiddleware(addVideoCaptionValidator),
asyncRetryTransactionMiddleware(createVideoCaption)
)
videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage',
authenticate,
asyncMiddleware(deleteVideoCaptionValidator),
@@ -40,6 +56,19 @@ export {
// ---------------------------------------------------------------------------
async function createGenerateVideoCaption (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const body = req.body as VideoCaptionGenerate
if (body.forceTranscription === true) {
await VideoJobInfoModel.abortAllTasks(video.uuid, 'pendingTranscription')
}
await createTranscriptionTaskIfNeeded(video)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function listVideoCaptions (req: express.Request, res: express.Response) {
const data = await VideoCaptionModel.listVideoCaptions(res.locals.onlyVideo.id)
@@ -74,7 +103,7 @@ async function deleteVideoCaption (req: express.Request, res: express.Response)
await federateVideoIfNeeded(video, false, t)
})
logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid)
logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid, lTags(video.uuid))
Hooks.runAction('action:api.video-caption.deleted', { caption: videoCaption, req, res })

View File

@@ -142,7 +142,8 @@ async function handleTorrentImport (req: express.Request, res: express.Response,
? 'torrent-file'
: 'magnet-uri',
videoImportId: videoImport.id,
preventException: false
preventException: false,
generateTranscription: body.generateTranscription
}
await JobQueue.Instance.createJob({ type: 'video-import', payload })

View File

@@ -1,83 +1,74 @@
import { UploadFilesForCheck } from 'express'
import validator from 'validator'
import { CONSTRAINTS_FIELDS, RUNNER_JOB_STATES } from '@server/initializers/constants.js'
import {
LiveRTMPHLSTranscodingSuccess,
RunnerJobSuccessPayload,
RunnerJobType,
RunnerJobUpdatePayload,
VideoStudioTranscodingSuccess,
TranscriptionSuccess,
VODAudioMergeTranscodingSuccess,
VODHLSTranscodingSuccess,
VODWebVideoTranscodingSuccess
VODWebVideoTranscodingSuccess,
VideoStudioTranscodingSuccess
} from '@peertube/peertube-models'
import { CONSTRAINTS_FIELDS, RUNNER_JOB_STATES } from '@server/initializers/constants.js'
import { UploadFilesForCheck } from 'express'
import validator from 'validator'
import { exists, isArray, isFileValid, isSafeFilename } from '../misc.js'
const RUNNER_JOBS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.RUNNER_JOBS
const runnerJobTypes = new Set([ 'vod-hls-transcoding', 'vod-web-video-transcoding', 'vod-audio-merge-transcoding' ])
function isRunnerJobTypeValid (value: RunnerJobType) {
export function isRunnerJobTypeValid (value: RunnerJobType) {
return runnerJobTypes.has(value)
}
function isRunnerJobSuccessPayloadValid (value: RunnerJobSuccessPayload, type: RunnerJobType, files: UploadFilesForCheck) {
export function isRunnerJobSuccessPayloadValid (value: RunnerJobSuccessPayload, type: RunnerJobType, files: UploadFilesForCheck) {
return isRunnerJobVODWebVideoResultPayloadValid(value as VODWebVideoTranscodingSuccess, type, files) ||
isRunnerJobVODHLSResultPayloadValid(value as VODHLSTranscodingSuccess, type, files) ||
isRunnerJobVODAudioMergeResultPayloadValid(value as VODHLSTranscodingSuccess, type, files) ||
isRunnerJobLiveRTMPHLSResultPayloadValid(value as LiveRTMPHLSTranscodingSuccess, type) ||
isRunnerJobVideoStudioResultPayloadValid(value as VideoStudioTranscodingSuccess, type, files)
isRunnerJobVideoStudioResultPayloadValid(value as VideoStudioTranscodingSuccess, type, files) ||
isRunnerJobTranscriptionResultPayloadValid(value as TranscriptionSuccess, type, files)
}
// ---------------------------------------------------------------------------
function isRunnerJobProgressValid (value: string) {
export function isRunnerJobProgressValid (value: string) {
return validator.default.isInt(value + '', RUNNER_JOBS_CONSTRAINTS_FIELDS.PROGRESS)
}
function isRunnerJobUpdatePayloadValid (value: RunnerJobUpdatePayload, type: RunnerJobType, files: UploadFilesForCheck) {
export function isRunnerJobUpdatePayloadValid (value: RunnerJobUpdatePayload, type: RunnerJobType, files: UploadFilesForCheck) {
return isRunnerJobVODWebVideoUpdatePayloadValid(value, type, files) ||
isRunnerJobVODHLSUpdatePayloadValid(value, type, files) ||
isRunnerJobVideoStudioUpdatePayloadValid(value, type, files) ||
isRunnerJobVODAudioMergeUpdatePayloadValid(value, type, files) ||
isRunnerJobLiveRTMPHLSUpdatePayloadValid(value, type, files)
isRunnerJobLiveRTMPHLSUpdatePayloadValid(value, type, files) ||
isRunnerJobTranscriptionUpdatePayloadValid(value, type, files)
}
// ---------------------------------------------------------------------------
function isRunnerJobTokenValid (value: string) {
export function isRunnerJobTokenValid (value: string) {
return exists(value) && validator.default.isLength(value, RUNNER_JOBS_CONSTRAINTS_FIELDS.TOKEN)
}
function isRunnerJobAbortReasonValid (value: string) {
export function isRunnerJobAbortReasonValid (value: string) {
return validator.default.isLength(value, RUNNER_JOBS_CONSTRAINTS_FIELDS.REASON)
}
function isRunnerJobErrorMessageValid (value: string) {
export function isRunnerJobErrorMessageValid (value: string) {
return validator.default.isLength(value, RUNNER_JOBS_CONSTRAINTS_FIELDS.ERROR_MESSAGE)
}
function isRunnerJobStateValid (value: any) {
export function isRunnerJobStateValid (value: any) {
return exists(value) && RUNNER_JOB_STATES[value] !== undefined
}
function isRunnerJobArrayOfStateValid (value: any) {
export function isRunnerJobArrayOfStateValid (value: any) {
return isArray(value) && value.every(v => isRunnerJobStateValid(v))
}
// ---------------------------------------------------------------------------
export {
isRunnerJobTypeValid,
isRunnerJobSuccessPayloadValid,
isRunnerJobUpdatePayloadValid,
isRunnerJobTokenValid,
isRunnerJobErrorMessageValid,
isRunnerJobProgressValid,
isRunnerJobAbortReasonValid,
isRunnerJobArrayOfStateValid,
isRunnerJobStateValid
}
// Private
// ---------------------------------------------------------------------------
function isRunnerJobVODWebVideoResultPayloadValid (
@@ -124,6 +115,15 @@ function isRunnerJobVideoStudioResultPayloadValid (
isFileValid({ files, field: 'payload[videoFile]', mimeTypeRegex: null, maxSize: null })
}
function isRunnerJobTranscriptionResultPayloadValid (
value: TranscriptionSuccess,
type: RunnerJobType,
files: UploadFilesForCheck
) {
return type === 'video-transcription' &&
isFileValid({ files, field: 'payload[vttFile]', mimeTypeRegex: null, maxSize: null })
}
// ---------------------------------------------------------------------------
function isRunnerJobVODWebVideoUpdatePayloadValid (
@@ -153,6 +153,15 @@ function isRunnerJobVODAudioMergeUpdatePayloadValid (
(!value || (typeof value === 'object' && Object.keys(value).length === 0))
}
function isRunnerJobTranscriptionUpdatePayloadValid (
value: RunnerJobUpdatePayload,
type: RunnerJobType,
_files: UploadFilesForCheck
) {
return type === 'video-transcription' &&
(!value || (typeof value === 'object' && Object.keys(value).length === 0))
}
function isRunnerJobLiveRTMPHLSUpdatePayloadValid (
value: RunnerJobUpdatePayload,
type: RunnerJobType,

View File

@@ -13,6 +13,7 @@ import {
import { decacheModule } from '@server/helpers/decache.js'
import { buildPath, root } from '@peertube/peertube-node-utils'
import { parseBytes, parseDurationToMs } from '../helpers/core-utils.js'
import { TranscriptionEngineName, WhisperBuiltinModelName } from '@peertube/peertube-transcription'
const require = createRequire(import.meta.url)
let config: IConfig = require('config')
@@ -515,6 +516,16 @@ const CONFIG = {
get ENABLED () { return config.get<boolean>('video_file.update.enabled') }
}
},
VIDEO_TRANSCRIPTION: {
get ENABLED () { return config.get<boolean>('video_transcription.enabled') },
get ENGINE () { return config.get<TranscriptionEngineName>('video_transcription.engine') },
get ENGINE_PATH () { return config.get<string>('video_transcription.engine_path') },
get MODEL () { return config.get<WhisperBuiltinModelName>('video_transcription.model') },
get MODEL_PATH () { return config.get<string>('video_transcription.model_path') },
REMOTE_RUNNERS: {
get ENABLED () { return config.get<boolean>('video_transcription.remote_runners.enabled') }
}
},
IMPORT: {
VIDEOS: {
get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },

View File

@@ -47,7 +47,7 @@ import { cpus } from 'os'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 850
const LAST_MIGRATION_VERSION = 855
// ---------------------------------------------------------------------------
@@ -213,7 +213,8 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
'notify': 1,
'federate-video': 1,
'create-user-export': 1,
'import-user-archive': 1
'import-user-archive': 1,
'video-transcription': 1
}
// Excluded keys are jobs that can be configured by admins
const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-import'>]: number } = {
@@ -241,7 +242,8 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
'notify': 5,
'federate-video': 3,
'create-user-export': 1,
'import-user-archive': 1
'import-user-archive': 1,
'video-transcription': 1
}
const JOB_TTL: { [id in JobType]: number } = {
'activitypub-http-broadcast': 60000 * 10, // 10 minutes
@@ -270,7 +272,8 @@ const JOB_TTL: { [id in JobType]: number } = {
'notify': 60000 * 5, // 5 minutes
'federate-video': 60000 * 5, // 5 minutes,
'create-user-export': 60000 * 60 * 24, // 24 hours
'import-user-archive': 60000 * 60 * 24 // 24 hours
'import-user-archive': 60000 * 60 * 24, // 24 hours
'video-transcription': 1000 * 3600 * 6 // 6 hours
}
const REPEAT_JOBS: { [ id in JobType ]?: RepeatOptions } = {
'videos-views-stats': {
@@ -282,7 +285,8 @@ const REPEAT_JOBS: { [ id in JobType ]?: RepeatOptions } = {
}
const JOB_PRIORITY = {
TRANSCODING: 100,
VIDEO_STUDIO: 150
VIDEO_STUDIO: 150,
TRANSCRIPTION: 200
}
const JOB_REMOVAL_OPTIONS = {
@@ -1013,7 +1017,9 @@ const DIRECTORIES = {
ORIGINAL_VIDEOS: CONFIG.STORAGE.ORIGINAL_VIDEO_FILES_DIR,
HLS_REDUNDANCY: join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
HLS_REDUNDANCY: join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls'),
LOCAL_PIP_DIRECTORY: join(CONFIG.STORAGE.BIN_DIR, 'pip')
}
const RESUMABLE_UPLOAD_SESSION_LIFETIME = SCHEDULER_INTERVALS_MS.REMOVE_DANGLING_RESUMABLE_UPLOADS

View File

@@ -0,0 +1,67 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
const { transaction } = utils
// Notification
{
await utils.queryInterface.addColumn('userNotification', 'videoCaptionId', {
type: Sequelize.INTEGER,
defaultValue: null,
allowNull: true,
references: {
model: 'videoCaption',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
}, { transaction })
}
// Notification settings
{
{
const data = {
type: Sequelize.INTEGER,
defaultValue: null,
allowNull: true
}
await utils.queryInterface.addColumn('userNotificationSetting', 'myVideoTranscriptionGenerated', data, { transaction })
}
{
const query = 'UPDATE "userNotificationSetting" SET "myVideoTranscriptionGenerated" = 1'
await utils.sequelize.query(query, { transaction })
}
{
const data = {
type: Sequelize.INTEGER,
defaultValue: null,
allowNull: false
}
await utils.queryInterface.changeColumn('userNotificationSetting', 'myVideoTranscriptionGenerated', data, { transaction })
}
}
// Video job info
{
await utils.queryInterface.addColumn('videoJobInfo', 'pendingTranscription', {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
}, { transaction })
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
down, up
}

View File

@@ -24,11 +24,13 @@ import { Hooks } from '@server/lib/plugins/hooks.js'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job.js'
import { isUserQuotaValid } from '@server/lib/user.js'
import { createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
import { buildNewFile } from '@server/lib/video-file.js'
import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import.js'
import { Job } from 'bullmq'
@@ -84,7 +86,7 @@ export {
async function processTorrentImport (job: Job, videoImport: MVideoImportDefault, payload: VideoImportTorrentPayload) {
logger.info('Processing torrent video import in job %s.', job.id)
const options = { type: payload.type, videoImportId: payload.videoImportId }
const options = { type: payload.type, generateTranscription: payload.generateTranscription, videoImportId: payload.videoImportId }
const target = {
torrentName: videoImport.torrentName ? getSecureTorrentName(videoImport.torrentName) : undefined,
@@ -96,7 +98,7 @@ async function processTorrentImport (job: Job, videoImport: MVideoImportDefault,
async function processYoutubeDLImport (job: Job, videoImport: MVideoImportDefault, payload: VideoImportYoutubeDLPayload) {
logger.info('Processing youtubeDL video import in job %s.', job.id)
const options = { type: payload.type, videoImportId: videoImport.id }
const options = { type: payload.type, generateTranscription: payload.generateTranscription, videoImportId: videoImport.id }
const youtubeDL = new YoutubeDLWrapper(
videoImport.targetUrl,
@@ -122,6 +124,7 @@ async function getVideoImportOrDie (payload: VideoImportPayload) {
type ProcessFileOptions = {
type: VideoImportYoutubeDLPayloadType | VideoImportTorrentPayloadType
generateTranscription: boolean
videoImportId: number
}
async function processFile (downloader: () => Promise<string>, videoImport: MVideoImportDefault, options: ProcessFileOptions) {
@@ -228,7 +231,14 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
})
})
await afterImportSuccess({ videoImport: videoImportUpdated, video, videoFile, user: videoImport.User, videoFileAlreadyLocked: true })
await afterImportSuccess({
videoImport: videoImportUpdated,
video,
videoFile,
user: videoImport.User,
generateTranscription: options.generateTranscription,
videoFileAlreadyLocked: true
})
} finally {
videoFileLockReleaser()
}
@@ -277,9 +287,12 @@ async function afterImportSuccess (options: {
video: MVideoFullLight
videoFile: MVideoFile
user: MUserId
videoFileAlreadyLocked: boolean
generateTranscription: boolean
}) {
const { video, videoFile, videoImport, user, videoFileAlreadyLocked } = options
const { video, videoFile, videoImport, user, generateTranscription, videoFileAlreadyLocked } = options
Notifier.Instance.notifyOnFinishedVideoImport({ videoImport: Object.assign(videoImport, { Video: video }), success: true })
@@ -294,6 +307,10 @@ async function afterImportSuccess (options: {
// Generate the storyboard in the job queue, and don't forget to federate an update after
await JobQueue.Instance.createJob(buildStoryboardJobIfNeeded({ video, federate: true }))
if (await VideoCaptionModel.hasVideoCaption(video.id) !== true && generateTranscription === true) {
await createTranscriptionTaskIfNeeded(video)
}
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
await JobQueue.Instance.createJob(
await buildMoveJob({ video, previousVideoState: VideoState.TO_IMPORT, type: 'move-to-object-storage' })

View File

@@ -1,9 +1,7 @@
import { Job } from 'bullmq'
import { remove } from 'fs-extra/esm'
import { readdir } from 'fs/promises'
import { join } from 'path'
import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@peertube/peertube-ffmpeg'
import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@peertube/peertube-models'
import { peertubeTruncate } from '@server/helpers/core-utils.js'
import { CONFIG } from '@server/initializers/config.js'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
@@ -16,7 +14,10 @@ import {
} from '@server/lib/paths.js'
import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js'
import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding.js'
import { createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
import { buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { isVideoInPublicDirectory } from '@server/lib/video-privacy.js'
import { moveToNextState } from '@server/lib/video-state.js'
import { VideoBlacklistModel } from '@server/models/video/video-blacklist.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
@@ -26,11 +27,12 @@ import { VideoLiveModel } from '@server/models/video/video-live.js'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@server/types/models/index.js'
import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@peertube/peertube-ffmpeg'
import { Job } from 'bullmq'
import { remove } from 'fs-extra/esm'
import { readdir } from 'fs/promises'
import { join } from 'path'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { JobQueue } from '../job-queue.js'
import { isVideoInPublicDirectory } from '@server/lib/video-privacy.js'
import { buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
const lTags = loggerTagsFactory('live', 'job')
@@ -174,9 +176,13 @@ async function saveReplayToExternalVideo (options: {
await replayVideo.addAndSaveThumbnail(thumbnail)
}
await moveToNextState({ video: replayVideo, isNewVideo: true })
await createStoryboardJob(replayVideo)
if (CONFIG.VIDEO_TRANSCRIPTION.ENABLED === true) {
await createTranscriptionTaskIfNeeded(replayVideo)
}
await moveToNextState({ video: replayVideo, isNewVideo: true })
}
async function replaceLiveByReplay (options: {
@@ -245,6 +251,10 @@ async function replaceLiveByReplay (options: {
await moveToNextState({ video: videoWithFiles, isNewVideo: true })
await createStoryboardJob(videoWithFiles)
if (CONFIG.VIDEO_TRANSCRIPTION.ENABLED === true) {
await createTranscriptionTaskIfNeeded(videoWithFiles)
}
}
async function assignReplayFilesToVideo (options: {

View File

@@ -0,0 +1,21 @@
import { VideoTranscriptionPayload } from '@peertube/peertube-models'
import { generateSubtitle } from '@server/lib/video-captions.js'
import { Job } from 'bullmq'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { VideoModel } from '../../../models/video/video.js'
const lTags = loggerTagsFactory('transcription')
export async function processVideoTranscription (job: Job) {
const payload = job.data as VideoTranscriptionPayload
logger.info('Processing video transcription in job %s.', job.id)
const video = await VideoModel.load(payload.videoUUID)
if (!video) {
logger.info('Do not process transcription job %d, video does not exist.', job.id, lTags(payload.videoUUID))
return
}
return generateSubtitle({ video })
}

View File

@@ -1,15 +1,3 @@
import {
FlowJob,
FlowProducer,
Job,
JobsOptions,
Queue,
QueueEvents,
QueueEventsOptions,
QueueOptions,
Worker,
WorkerOptions
} from 'bullmq'
import { pick, timeoutPromise } from '@peertube/peertube-core-utils'
import {
ActivitypubFollowPayload,
@@ -37,12 +25,25 @@ import {
VideoLiveEndingPayload,
VideoRedundancyPayload,
VideoStudioEditionPayload,
VideoTranscodingPayload
VideoTranscodingPayload,
VideoTranscriptionPayload
} from '@peertube/peertube-models'
import { parseDurationToMs } from '@server/helpers/core-utils.js'
import { jobStates } from '@server/helpers/custom-validators/jobs.js'
import { CONFIG } from '@server/initializers/config.js'
import { processVideoRedundancy } from '@server/lib/job-queue/handlers/video-redundancy.js'
import {
FlowJob,
FlowProducer,
Job,
JobsOptions,
Queue,
QueueEvents,
QueueEventsOptions,
QueueOptions,
Worker,
WorkerOptions
} from 'bullmq'
import { logger } from '../../helpers/logger.js'
import { JOB_ATTEMPTS, JOB_CONCURRENCY, JOB_REMOVAL_OPTIONS, JOB_TTL, REPEAT_JOBS, WEBSERVER } from '../../initializers/constants.js'
import { Hooks } from '../plugins/hooks.js'
@@ -58,10 +59,13 @@ import { processActivityPubHttpUnicast } from './handlers/activitypub-http-unica
import { refreshAPObject } from './handlers/activitypub-refresher.js'
import { processActorKeys } from './handlers/actor-keys.js'
import { processAfterVideoChannelImport } from './handlers/after-video-channel-import.js'
import { processCreateUserExport } from './handlers/create-user-export.js'
import { processEmail } from './handlers/email.js'
import { processFederateVideo } from './handlers/federate-video.js'
import { processGenerateStoryboard } from './handlers/generate-storyboard.js'
import { processImportUserArchive } from './handlers/import-user-archive.js'
import { processManageVideoTorrent } from './handlers/manage-video-torrent.js'
import { onMoveToFileSystemFailure, processMoveToFileSystem } from './handlers/move-to-file-system.js'
import { onMoveToObjectStorageFailure, processMoveToObjectStorage } from './handlers/move-to-object-storage.js'
import { processNotify } from './handlers/notify.js'
import { processTranscodingJobBuilder } from './handlers/transcoding-job-builder.js'
@@ -71,10 +75,8 @@ import { processVideoImport } from './handlers/video-import.js'
import { processVideoLiveEnding } from './handlers/video-live-ending.js'
import { processVideoStudioEdition } from './handlers/video-studio-edition.js'
import { processVideoTranscoding } from './handlers/video-transcoding.js'
import { processVideoTranscription } from './handlers/video-transcription.js'
import { processVideosViewsStats } from './handlers/video-views-stats.js'
import { onMoveToFileSystemFailure, processMoveToFileSystem } from './handlers/move-to-file-system.js'
import { processCreateUserExport } from './handlers/create-user-export.js'
import { processImportUserArchive } from './handlers/import-user-archive.js'
export type CreateJobArgument =
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@@ -104,7 +106,8 @@ export type CreateJobArgument =
{ type: 'federate-video', payload: FederateVideoPayload } |
{ type: 'create-user-export', payload: CreateUserExportPayload } |
{ type: 'generate-video-storyboard', payload: GenerateStoryboardPayload } |
{ type: 'import-user-archive', payload: ImportUserArchivePayload }
{ type: 'import-user-archive', payload: ImportUserArchivePayload } |
{ type: 'video-transcription', payload: VideoTranscriptionPayload }
export type CreateJobOptions = {
delay?: number
@@ -139,7 +142,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
'videos-views-stats': processVideosViewsStats,
'generate-video-storyboard': processGenerateStoryboard,
'create-user-export': processCreateUserExport,
'import-user-archive': processImportUserArchive
'import-user-archive': processImportUserArchive,
'video-transcription': processVideoTranscription
}
const errorHandlers: { [id in JobType]?: (job: Job, err: any) => Promise<any> } = {
@@ -171,10 +175,11 @@ const jobTypes: JobType[] = [
'video-live-ending',
'video-redundancy',
'video-studio-edition',
'video-transcoding',
'video-transcription',
'videos-views-stats',
'create-user-export',
'import-user-archive'
'import-user-archive',
'video-transcoding'
]
const silentFailure = new Set<JobType>([ 'activitypub-http-unicast' ])
@@ -561,6 +566,5 @@ class JobQueue {
// ---------------------------------------------------------------------------
export {
jobTypes,
JobQueue
JobQueue, jobTypes
}

View File

@@ -107,6 +107,8 @@ export class LocalVideoCreator {
this.channel = options.channel
this.videoAttributeResultHook = options.videoAttributeResultHook
this.lTags = options.lTags
}
async create () {
@@ -201,8 +203,11 @@ export class LocalVideoCreator {
if (this.videoFile) {
transaction.afterCommit(() => {
addVideoJobsAfterCreation({ video: this.video, videoFile: this.videoFile })
.catch(err => logger.error('Cannot build new video jobs of %s.', this.video.uuid, { err, ...this.lTags(this.video.uuid) }))
addVideoJobsAfterCreation({
video: this.video,
videoFile: this.videoFile,
generateTranscription: this.videoAttributes.generateTranscription ?? true
}).catch(err => logger.error('Cannot build new video jobs of %s.', this.video.uuid, { err, ...this.lTags(this.video.uuid) }))
})
} else {
await federateVideoIfNeeded(this.video, true, transaction)

View File

@@ -11,6 +11,7 @@ import {
MCommentOwnerVideo,
MPlugin,
MVideoAccountLight,
MVideoCaptionVideo,
MVideoFullLight
} from '../../types/models/index.js'
import { JobQueue } from '../job-queue/index.js'
@@ -41,7 +42,8 @@ import {
OwnedPublicationAfterTranscoding,
RegistrationRequestForModerators,
StudioEditionFinishedForOwner,
UnblacklistForOwner
UnblacklistForOwner,
VideoTranscriptionGeneratedForOwner
} from './shared/index.js'
const lTags = loggerTagsFactory('notifier')
@@ -69,7 +71,8 @@ class Notifier {
newAbuseMessage: [ NewAbuseMessageForReporter, NewAbuseMessageForModerators ],
newPeertubeVersion: [ NewPeerTubeVersionForAdmins ],
newPluginVersion: [ NewPluginVersionForAdmins ],
videoStudioEditionFinished: [ StudioEditionFinishedForOwner ]
videoStudioEditionFinished: [ StudioEditionFinishedForOwner ],
videoTranscriptionGenerated: [ VideoTranscriptionGeneratedForOwner ]
}
private static instance: Notifier
@@ -275,6 +278,16 @@ class Notifier {
.catch(err => logger.error('Cannot notify on finished studio edition %s.', video.url, { err }))
}
notifyOfGeneratedVideoTranscription (caption: MVideoCaptionVideo) {
const models = this.notificationModels.videoTranscriptionGenerated
const video = caption.Video
logger.debug('Notify on generated video transcription', { language: caption.language, video: video.url, ...lTags() })
this.sendNotifications(models, caption)
.catch(err => logger.error('Cannot notify on generated video transcription %s of video %s.', caption.language, video.url, { err }))
}
private async notify <T> (object: AbstractNotification<T>) {
await object.prepare()

View File

@@ -0,0 +1 @@
export * from './video-transcription-generated-for-owner.js'

View File

@@ -0,0 +1,63 @@
import { UserNotificationType } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { VIDEO_LANGUAGES, WEBSERVER } from '@server/initializers/constants.js'
import { UserNotificationModel } from '@server/models/user/user-notification.js'
import { UserModel } from '@server/models/user/user.js'
import { MUserDefault, MUserWithNotificationSetting, MVideoCaptionVideo, UserNotificationModelForApi } from '@server/types/models/index.js'
import { AbstractNotification } from '../common/abstract-notification.js'
export class VideoTranscriptionGeneratedForOwner extends AbstractNotification <MVideoCaptionVideo> {
private user: MUserDefault
async prepare () {
this.user = await UserModel.loadByVideoId(this.payload.videoId)
}
log () {
logger.info(
'Notifying user %s the transcription %s of video %s is generated.',
this.user.username, this.payload.language, this.payload.Video.url
)
}
getSetting (user: MUserWithNotificationSetting) {
return user.NotificationSetting.myVideoTranscriptionGenerated
}
getTargetUsers () {
if (!this.user) return []
return [ this.user ]
}
createNotification (user: MUserWithNotificationSetting) {
const notification = UserNotificationModel.build<UserNotificationModelForApi>({
type: UserNotificationType.MY_VIDEO_TRANSCRIPTION_GENERATED,
userId: user.id,
videoCaptionId: this.payload.id
})
notification.VideoCaption = this.payload
return notification
}
createEmail (to: string) {
const video = this.payload.Video
const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
const language = VIDEO_LANGUAGES[this.payload.language]
return {
to,
subject: `Transcription in ${language} of your video ${video.name} has been generated`,
text: `Transcription in ${language} of your video ${video.name} has been generated.`,
locals: {
title: 'Transcription has been generated',
action: {
text: 'View video',
url: videoUrl
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
export * from './abuse/index.js'
export * from './blacklist/index.js'
export * from './caption/index.js'
export * from './comment/index.js'
export * from './common/index.js'
export * from './follow/index.js'

View File

@@ -6,6 +6,8 @@ import {
RunnerJobStateType,
RunnerJobStudioTranscodingPayload,
RunnerJobSuccessPayload,
RunnerJobTranscriptionPayload,
RunnerJobTranscriptionPrivatePayload,
RunnerJobType,
RunnerJobUpdatePayload,
RunnerJobVODAudioMergeTranscodingPayload,
@@ -51,6 +53,11 @@ type CreateRunnerJobArg =
type: Extract<RunnerJobType, 'video-studio-transcoding'>
payload: RunnerJobStudioTranscodingPayload
privatePayload: RunnerJobVideoStudioTranscodingPrivatePayload
} |
{
type: Extract<RunnerJobType, 'video-transcription'>
payload: RunnerJobTranscriptionPayload
privatePayload: RunnerJobTranscriptionPrivatePayload
}
export abstract class AbstractJobHandler <C, U extends RunnerJobUpdatePayload, S extends RunnerJobSuccessPayload> {

View File

@@ -11,7 +11,7 @@ import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MRunnerJob } from '@server/types/models/runners/index.js'
import { AbstractJobHandler } from './abstract-job-handler.js'
import { loadTranscodingRunnerVideo } from './shared/index.js'
import { loadRunnerVideo } from './shared/utils.js'
// eslint-disable-next-line max-len
export abstract class AbstractVODTranscodingJobHandler <C, U extends RunnerJobUpdatePayload, S extends RunnerJobSuccessPayload> extends AbstractJobHandler<C, U, S> {
@@ -38,7 +38,7 @@ export abstract class AbstractVODTranscodingJobHandler <C, U extends RunnerJobUp
}) {
if (options.nextState !== RunnerJobState.ERRORED) return
const video = await loadTranscodingRunnerVideo(options.runnerJob, this.lTags)
const video = await loadRunnerVideo(options.runnerJob, this.lTags)
if (!video) return
await moveToFailedTranscodingState(video)
@@ -51,7 +51,7 @@ export abstract class AbstractVODTranscodingJobHandler <C, U extends RunnerJobUp
}) {
const { runnerJob } = options
const video = await loadTranscodingRunnerVideo(options.runnerJob, this.lTags)
const video = await loadRunnerVideo(options.runnerJob, this.lTags)
if (!video) return
const pending = await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscode')

View File

@@ -1,6 +1,7 @@
export * from './abstract-job-handler.js'
export * from './live-rtmp-hls-transcoding-job-handler.js'
export * from './runner-job-handlers.js'
export * from './transcription-job-handler.js'
export * from './video-studio-transcoding-job-handler.js'
export * from './vod-audio-merge-transcoding-job-handler.js'
export * from './vod-hls-transcoding-job-handler.js'

View File

@@ -1,7 +1,8 @@
import { MRunnerJob } from '@server/types/models/runners/index.js'
import { RunnerJobSuccessPayload, RunnerJobType, RunnerJobUpdatePayload } from '@peertube/peertube-models'
import { MRunnerJob } from '@server/types/models/runners/index.js'
import { AbstractJobHandler } from './abstract-job-handler.js'
import { LiveRTMPHLSTranscodingJobHandler } from './live-rtmp-hls-transcoding-job-handler.js'
import { TranscriptionJobHandler } from './transcription-job-handler.js'
import { VideoStudioTranscodingJobHandler } from './video-studio-transcoding-job-handler.js'
import { VODAudioMergeTranscodingJobHandler } from './vod-audio-merge-transcoding-job-handler.js'
import { VODHLSTranscodingJobHandler } from './vod-hls-transcoding-job-handler.js'
@@ -12,7 +13,8 @@ const processors: Record<RunnerJobType, new() => AbstractJobHandler<unknown, Run
'vod-hls-transcoding': VODHLSTranscodingJobHandler,
'vod-audio-merge-transcoding': VODAudioMergeTranscodingJobHandler,
'live-rtmp-hls-transcoding': LiveRTMPHLSTranscodingJobHandler,
'video-studio-transcoding': VideoStudioTranscodingJobHandler
'video-studio-transcoding': VideoStudioTranscodingJobHandler,
'video-transcription': TranscriptionJobHandler
}
export function getRunnerJobHandlerClass (job: MRunnerJob) {

View File

@@ -1 +0,0 @@
export * from './vod-helpers.js'

View File

@@ -23,12 +23,12 @@ export async function onVODWebVideoOrAudioMergeTranscodingJob (options: {
await onTranscodingEnded({ isNewVideo: privatePayload.isNewVideo, moveVideoToNextState: true, video })
}
export async function loadTranscodingRunnerVideo (runnerJob: MRunnerJob, lTags: LoggerTagsFn) {
export async function loadRunnerVideo (runnerJob: MRunnerJob, lTags: LoggerTagsFn) {
const videoUUID = runnerJob.privatePayload.videoUUID
const video = await VideoModel.loadFull(videoUUID)
if (!video) {
logger.info('Video %s does not exist anymore after transcoding runner job.', videoUUID, lTags(videoUUID))
logger.info('Video %s does not exist anymore after runner job.', videoUUID, lTags(videoUUID))
return undefined
}

View File

@@ -0,0 +1,101 @@
import {
RunnerJobState,
RunnerJobStateType,
RunnerJobTranscriptionPayload,
RunnerJobTranscriptionPrivatePayload,
RunnerJobUpdatePayload,
TranscriptionSuccess
} from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { JOB_PRIORITY } from '@server/initializers/constants.js'
import { onTranscriptionEnded } from '@server/lib/video-captions.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MVideoUUID } from '@server/types/models/index.js'
import { MRunnerJob } from '@server/types/models/runners/index.js'
import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
import { AbstractJobHandler } from './abstract-job-handler.js'
import { loadRunnerVideo } from './shared/utils.js'
type CreateOptions = {
video: MVideoUUID
}
export class TranscriptionJobHandler extends AbstractJobHandler<CreateOptions, RunnerJobUpdatePayload, TranscriptionSuccess> {
protected isAbortSupported () {
return true
}
protected specificUpdate (_options: {
runnerJob: MRunnerJob
}) {
// empty
}
protected specificAbort (_options: {
runnerJob: MRunnerJob
}) {
// empty
}
protected async specificError (options: {
runnerJob: MRunnerJob
nextState: RunnerJobStateType
}) {
if (options.nextState !== RunnerJobState.ERRORED) return
await VideoJobInfoModel.decrease(options.runnerJob.privatePayload.videoUUID, 'pendingTranscription')
}
protected async specificCancel (options: {
runnerJob: MRunnerJob
}) {
await VideoJobInfoModel.decrease(options.runnerJob.privatePayload.videoUUID, 'pendingTranscription')
}
async create (options: CreateOptions) {
const { video } = options
const jobUUID = buildUUID()
const payload: RunnerJobTranscriptionPayload = {
input: {
videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid)
}
}
const privatePayload: RunnerJobTranscriptionPrivatePayload = {
videoUUID: video.uuid
}
const job = await this.createRunnerJob({
type: 'video-transcription',
jobUUID,
payload,
privatePayload,
priority: JOB_PRIORITY.TRANSCODING
})
await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscription')
return job
}
// ---------------------------------------------------------------------------
protected async specificComplete (options: {
runnerJob: MRunnerJob
resultPayload: TranscriptionSuccess
}) {
const { runnerJob, resultPayload } = options
const video = await loadRunnerVideo(runnerJob, this.lTags)
if (!video) return
await onTranscriptionEnded({
video,
language: resultPayload.inputLanguage,
vttPath: resultPayload.vttFile as string,
lTags: this.lTags().tags
})
}
}

View File

@@ -19,7 +19,7 @@ import { MRunnerJob } from '@server/types/models/runners/index.js'
import { basename } from 'path'
import { generateRunnerEditionTranscodingVideoInputFileUrl, generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
import { AbstractJobHandler } from './abstract-job-handler.js'
import { loadTranscodingRunnerVideo } from './shared/index.js'
import { loadRunnerVideo } from './shared/utils.js'
type CreateOptions = {
video: MVideo
@@ -108,7 +108,7 @@ export class VideoStudioTranscodingJobHandler extends AbstractJobHandler<CreateO
const { runnerJob, resultPayload } = options
const privatePayload = runnerJob.privatePayload as RunnerJobVideoStudioTranscodingPrivatePayload
const video = await loadTranscodingRunnerVideo(runnerJob, this.lTags)
const video = await loadRunnerVideo(runnerJob, this.lTags)
if (!video) {
await safeCleanupStudioTMPFiles(privatePayload.originalTasks)
@@ -149,7 +149,7 @@ export class VideoStudioTranscodingJobHandler extends AbstractJobHandler<CreateO
const payload = runnerJob.privatePayload as RunnerJobVideoStudioTranscodingPrivatePayload
await safeCleanupStudioTMPFiles(payload.originalTasks)
const video = await loadTranscodingRunnerVideo(options.runnerJob, this.lTags)
const video = await loadRunnerVideo(options.runnerJob, this.lTags)
if (!video) return
return video.setNewState(VideoState.PUBLISHED, false, undefined)

View File

@@ -1,18 +1,18 @@
import { logger } from '@server/helpers/logger.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MVideo } from '@server/types/models/index.js'
import { MRunnerJob } from '@server/types/models/runners/index.js'
import { pick } from '@peertube/peertube-core-utils'
import { buildUUID } from '@peertube/peertube-node-utils'
import {
RunnerJobUpdatePayload,
RunnerJobVODAudioMergeTranscodingPayload,
RunnerJobVODWebVideoTranscodingPrivatePayload,
VODAudioMergeTranscodingSuccess
} from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { logger } from '@server/helpers/logger.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MVideo } from '@server/types/models/index.js'
import { MRunnerJob } from '@server/types/models/runners/index.js'
import { generateRunnerTranscodingVideoInputFileUrl, generateRunnerTranscodingVideoPreviewFileUrl } from '../runner-urls.js'
import { AbstractVODTranscodingJobHandler } from './abstract-vod-transcoding-job-handler.js'
import { loadTranscodingRunnerVideo, onVODWebVideoOrAudioMergeTranscodingJob } from './shared/index.js'
import { loadRunnerVideo, onVODWebVideoOrAudioMergeTranscodingJob } from './shared/utils.js'
type CreateOptions = {
video: MVideo
@@ -71,7 +71,7 @@ export class VODAudioMergeTranscodingJobHandler extends AbstractVODTranscodingJo
const { runnerJob, resultPayload } = options
const privatePayload = runnerJob.privatePayload as RunnerJobVODWebVideoTranscodingPrivatePayload
const video = await loadTranscodingRunnerVideo(runnerJob, this.lTags)
const video = await loadRunnerVideo(runnerJob, this.lTags)
if (!video) return
const videoFilePath = resultPayload.videoFile as string

View File

@@ -1,3 +1,11 @@
import { pick } from '@peertube/peertube-core-utils'
import {
RunnerJobUpdatePayload,
RunnerJobVODHLSTranscodingPayload,
RunnerJobVODHLSTranscodingPrivatePayload,
VODHLSTranscodingSuccess
} from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { logger } from '@server/helpers/logger.js'
import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding.js'
import { onHLSVideoFileTranscoding } from '@server/lib/transcoding/hls-transcoding.js'
@@ -5,17 +13,9 @@ import { removeAllWebVideoFiles } from '@server/lib/video-file.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MVideo } from '@server/types/models/index.js'
import { MRunnerJob } from '@server/types/models/runners/index.js'
import { pick } from '@peertube/peertube-core-utils'
import { buildUUID } from '@peertube/peertube-node-utils'
import {
RunnerJobUpdatePayload,
RunnerJobVODHLSTranscodingPayload,
RunnerJobVODHLSTranscodingPrivatePayload,
VODHLSTranscodingSuccess
} from '@peertube/peertube-models'
import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
import { AbstractVODTranscodingJobHandler } from './abstract-vod-transcoding-job-handler.js'
import { loadTranscodingRunnerVideo } from './shared/index.js'
import { loadRunnerVideo } from './shared/utils.js'
type CreateOptions = {
video: MVideo
@@ -74,7 +74,7 @@ export class VODHLSTranscodingJobHandler extends AbstractVODTranscodingJobHandle
const { runnerJob, resultPayload } = options
const privatePayload = runnerJob.privatePayload as RunnerJobVODHLSTranscodingPrivatePayload
const video = await loadTranscodingRunnerVideo(runnerJob, this.lTags)
const video = await loadRunnerVideo(runnerJob, this.lTags)
if (!video) return
const videoFilePath = resultPayload.videoFile as string

View File

@@ -1,18 +1,18 @@
import { logger } from '@server/helpers/logger.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MVideo } from '@server/types/models/index.js'
import { MRunnerJob } from '@server/types/models/runners/index.js'
import { pick } from '@peertube/peertube-core-utils'
import { buildUUID } from '@peertube/peertube-node-utils'
import {
RunnerJobUpdatePayload,
RunnerJobVODWebVideoTranscodingPayload,
RunnerJobVODWebVideoTranscodingPrivatePayload,
VODWebVideoTranscodingSuccess
} from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { logger } from '@server/helpers/logger.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MVideo } from '@server/types/models/index.js'
import { MRunnerJob } from '@server/types/models/runners/index.js'
import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
import { AbstractVODTranscodingJobHandler } from './abstract-vod-transcoding-job-handler.js'
import { loadTranscodingRunnerVideo, onVODWebVideoOrAudioMergeTranscodingJob } from './shared/index.js'
import { loadRunnerVideo, onVODWebVideoOrAudioMergeTranscodingJob } from './shared/utils.js'
type CreateOptions = {
video: MVideo
@@ -70,7 +70,7 @@ export class VODWebVideoTranscodingJobHandler extends AbstractVODTranscodingJobH
const { runnerJob, resultPayload } = options
const privatePayload = runnerJob.privatePayload as RunnerJobVODWebVideoTranscodingPrivatePayload
const video = await loadTranscodingRunnerVideo(runnerJob, this.lTags)
const video = await loadRunnerVideo(runnerJob, this.lTags)
if (!video) return
const videoFilePath = resultPayload.videoFile as string

View File

@@ -192,6 +192,9 @@ class ServerConfigManager {
enabled: CONFIG.VIDEO_FILE.UPDATE.ENABLED
}
},
videoTranscription: {
enabled: CONFIG.VIDEO_TRANSCRIPTION.ENABLED
},
import: {
videos: {
http: {

View File

@@ -93,7 +93,8 @@ export class UserSettingsImporter extends AbstractUserImporter <UserSettingsExpo
autoInstanceFollowing: settingsImportData.autoInstanceFollowing,
newPeerTubeVersion: settingsImportData.newPeerTubeVersion,
newPluginVersion: settingsImportData.newPluginVersion,
myVideoStudioEditionFinished: settingsImportData.myVideoStudioEditionFinished
myVideoStudioEditionFinished: settingsImportData.myVideoStudioEditionFinished,
myVideoTranscriptionGenerated: settingsImportData.myVideoTranscriptionGenerated
}
await UserNotificationSettingModel.updateUserSettings(values, this.user.id)

View File

@@ -274,7 +274,8 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
autoInstanceFollowing: UserNotificationSettingValue.WEB,
newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
newPluginVersion: UserNotificationSettingValue.WEB,
myVideoStudioEditionFinished: UserNotificationSettingValue.WEB
myVideoStudioEditionFinished: UserNotificationSettingValue.WEB,
myVideoTranscriptionGenerated: UserNotificationSettingValue.WEB
}
return UserNotificationSettingModel.create(values, { transaction: t })

View File

@@ -1,7 +1,25 @@
import { hasAudioStream } from '@peertube/peertube-ffmpeg'
import { buildSUUID } from '@peertube/peertube-node-utils'
import { AbstractTranscriber, TranscriptionModel, WhisperBuiltinModel, transcriberFactory } from '@peertube/peertube-transcription'
import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils.js'
import { isVideoCaptionLanguageValid } from '@server/helpers/custom-validators/video-captions.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { DIRECTORIES } from '@server/initializers/constants.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { MVideo, MVideoCaption } from '@server/types/models/index.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideo, MVideoCaption, MVideoFullLight, MVideoUUID, MVideoUrl } from '@server/types/models/index.js'
import { ensureDir, remove } from 'fs-extra/esm'
import { join } from 'path'
import { federateVideoIfNeeded } from './activitypub/videos/federate.js'
import { JobQueue } from './job-queue/job-queue.js'
import { Notifier } from './notifier/notifier.js'
import { TranscriptionJobHandler } from './runners/index.js'
import { VideoPathManager } from './video-path-manager.js'
const lTags = loggerTagsFactory('video-caption')
export async function createLocalCaption (options: {
video: MVideo
@@ -22,5 +40,122 @@ export async function createLocalCaption (options: {
await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
})
return videoCaption
return Object.assign(videoCaption, { Video: video })
}
export async function createTranscriptionTaskIfNeeded (video: MVideoUUID & MVideoUrl) {
if (CONFIG.VIDEO_TRANSCRIPTION.ENABLED !== true) return
logger.info(`Creating transcription job for ${video.url}`, lTags(video.uuid))
if (CONFIG.VIDEO_TRANSCRIPTION.REMOTE_RUNNERS.ENABLED === true) {
await new TranscriptionJobHandler().create({ video })
} else {
await JobQueue.Instance.createJob({ type: 'video-transcription', payload: { videoUUID: video.uuid } })
}
await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscription')
}
// ---------------------------------------------------------------------------
// Transcription task
// ---------------------------------------------------------------------------
let transcriber: AbstractTranscriber
export async function generateSubtitle (options: {
video: MVideoUUID
}) {
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(options.video.uuid)
const outputPath = join(CONFIG.STORAGE.TMP_DIR, 'transcription', buildSUUID())
await ensureDir(outputPath)
const binDirectory = join(DIRECTORIES.LOCAL_PIP_DIRECTORY, 'bin')
try {
// Lazy load the transcriber
if (!transcriber) {
transcriber = transcriberFactory.createFromEngineName({
engineName: CONFIG.VIDEO_TRANSCRIPTION.ENGINE,
enginePath: CONFIG.VIDEO_TRANSCRIPTION.ENGINE_PATH,
logger,
binDirectory
})
if (!CONFIG.VIDEO_TRANSCRIPTION.ENGINE_PATH) {
logger.info(`Installing transcriber ${transcriber.engine.name} to generate subtitles`, lTags())
await transcriber.install(DIRECTORIES.LOCAL_PIP_DIRECTORY)
}
}
const video = await VideoModel.loadFull(options.video.uuid)
const file = video.getMaxQualityFile().withVideoOrPlaylist(video)
await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => {
if (await hasAudioStream(videoInputPath) !== true) {
logger.info(
`Do not run transcription for ${video.uuid} in ${outputPath} because it does not contain an audio stream`,
lTags(video.uuid)
)
return
}
logger.info(`Running transcription for ${video.uuid} in ${outputPath}`, lTags(video.uuid))
const transcriptFile = await transcriber.transcribe({
mediaFilePath: videoInputPath,
model: CONFIG.VIDEO_TRANSCRIPTION.MODEL_PATH
? await TranscriptionModel.fromPath(CONFIG.VIDEO_TRANSCRIPTION.MODEL_PATH)
: new WhisperBuiltinModel(CONFIG.VIDEO_TRANSCRIPTION.MODEL),
transcriptDirectory: outputPath,
format: 'vtt'
})
await onTranscriptionEnded({ video, language: transcriptFile.language, vttPath: transcriptFile.path })
})
} finally {
if (outputPath) await remove(outputPath)
inputFileMutexReleaser()
}
}
export async function onTranscriptionEnded (options: {
video: MVideoFullLight
language: string
vttPath: string
lTags?: (string | number)[]
}) {
const { video, language, vttPath, lTags: customLTags = [] } = options
await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscription')
if (!isVideoCaptionLanguageValid(language)) {
logger.warn(`Invalid transcription language for video ${video.uuid}`, this.lTags(video.uuid))
return
}
if (!video.language) {
video.language = language
await video.save()
}
const caption = await createLocalCaption({
video,
language,
path: vttPath
})
await sequelizeTypescript.transaction(async t => {
await federateVideoIfNeeded(video, false, t)
})
Notifier.Instance.notifyOfGeneratedVideoTranscription(caption)
logger.info(`Transcription ended for ${video.uuid}`, lTags(video.uuid, ...customLTags))
}

View File

@@ -8,7 +8,7 @@ import { VideoFileModel } from '@server/models/video/video-file.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { MVideo, MVideoFile, MVideoId, MVideoWithAllFiles } from '@server/types/models/index.js'
import { FfprobeData } from 'fluent-ffmpeg'
import { move, remove } from 'fs-extra'
import { move, remove } from 'fs-extra/esm'
import { lTags } from './object-storage/shared/index.js'
import { storeOriginalVideoFile } from './object-storage/videos.js'
import { generateHLSVideoFilename, generateWebVideoFilename } from './paths.js'

View File

@@ -3,6 +3,7 @@ import { CONFIG } from '@server/initializers/config.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MVideo, MVideoFile, MVideoFullLight, MVideoUUID } from '@server/types/models/index.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from './job-queue/job-queue.js'
import { createTranscriptionTaskIfNeeded } from './video-captions.js'
import { moveFilesIfPrivacyChanged } from './video-privacy.js'
export async function buildMoveJob (options: {
@@ -57,8 +58,9 @@ export function buildStoryboardJobIfNeeded (options: {
export async function addVideoJobsAfterCreation (options: {
video: MVideo
videoFile: MVideoFile
generateTranscription: boolean
}) {
const { video, videoFile } = options
const { video, videoFile, generateTranscription } = options
const jobs: (CreateJobArgument & CreateJobOptions)[] = [
{
@@ -105,7 +107,11 @@ export async function addVideoJobsAfterCreation (options: {
})
}
return JobQueue.Instance.createSequentialJobFlow(...jobs)
await JobQueue.Instance.createSequentialJobFlow(...jobs)
if (generateTranscription === true && CONFIG.VIDEO_TRANSCRIPTION.ENABLED === true) {
await createTranscriptionTaskIfNeeded(video)
}
}
export async function addVideoJobsAfterUpdate (options: {

View File

@@ -259,6 +259,7 @@ async function buildYoutubeDLImport (options: {
type: 'youtube-dl' as 'youtube-dl',
videoImportId: videoImport.id,
fileExt,
generateTranscription: importDataOverride.generateTranscription ?? true,
// If part of a sync process, there is a parent job that will aggregate children results
preventException: !!channelSync
}

View File

@@ -1,6 +1,10 @@
import { HttpStatusCode, ServerErrorCode, UserRight, VideoCaptionGenerate } from '@peertube/peertube-models'
import { isBooleanValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import express from 'express'
import { body, param } from 'express-validator'
import { UserRight } from '@peertube/peertube-models'
import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions.js'
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.js'
@@ -14,7 +18,7 @@ import {
isValidVideoPasswordHeader
} from '../shared/index.js'
const addVideoCaptionValidator = [
export const addVideoCaptionValidator = [
isValidVideoIdParam('videoId'),
param('captionLanguage')
@@ -41,7 +45,75 @@ const addVideoCaptionValidator = [
}
]
const deleteVideoCaptionValidator = [
export const generateVideoCaptionValidator = [
isValidVideoIdParam('videoId'),
body('forceTranscription')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (CONFIG.VIDEO_TRANSCRIPTION.ENABLED !== true) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Video transcription is disabled on this instance'
})
}
if (!await doesVideoExist(req.params.videoId, res)) return
const video = res.locals.videoAll
if (video.remote) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot run transcription job on a remote video'
})
}
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return
// Check the video has not already a caption
const captions = await VideoCaptionModel.listVideoCaptions(video.id)
if (captions.length !== 0) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'This video already has captions'
})
}
// Bypass "video is already transcribed" check
const body = req.body as VideoCaptionGenerate
if (body.forceTranscription === true) {
if (user.hasRight(UserRight.UPDATE_ANY_VIDEO) !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Only admins can force transcription'
})
}
return next()
}
const info = await VideoJobInfoModel.load(video.id)
if (info && info.pendingTranscription > 0) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
type: ServerErrorCode.VIDEO_ALREADY_BEING_TRANSCRIBED,
message: 'This video is already being transcribed'
})
}
return next()
}
]
export const deleteVideoCaptionValidator = [
isValidVideoIdParam('videoId'),
param('captionLanguage')
@@ -60,7 +132,7 @@ const deleteVideoCaptionValidator = [
}
]
const listVideoCaptionsValidator = [
export const listVideoCaptionsValidator = [
isValidVideoIdParam('videoId'),
isValidVideoPasswordHeader(),
@@ -75,9 +147,3 @@ const listVideoCaptionsValidator = [
return next()
}
]
export {
addVideoCaptionValidator,
listVideoCaptionsValidator,
deleteVideoCaptionValidator
}

View File

@@ -183,7 +183,12 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery {
"Account->Actor->Server"."id" AS "Account.Actor.Server.id",
"Account->Actor->Server"."host" AS "Account.Actor.Server.host",
"UserRegistration"."id" AS "UserRegistration.id",
"UserRegistration"."username" AS "UserRegistration.username"`
"UserRegistration"."username" AS "UserRegistration.username",
"VideoCaption"."id" AS "VideoCaption.id",
"VideoCaption"."language" AS "VideoCaption.language",
"VideoCaption->Video"."id" AS "VideoCaption.Video.id",
"VideoCaption->Video"."uuid" AS "VideoCaption.Video.uuid",
"VideoCaption->Video"."name" AS "VideoCaption.Video.name"`
}
private getJoins () {
@@ -269,6 +274,11 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery {
LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
) ON "UserNotificationModel"."accountId" = "Account"."id"
LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id"`
LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id"
LEFT JOIN (
"videoCaption" AS "VideoCaption"
INNER JOIN "video" AS "VideoCaption->Video" ON "VideoCaption"."videoId" = "VideoCaption->Video"."id"
) ON "UserNotificationModel"."videoCaptionId" = "VideoCaption"."id"`
}
}

View File

@@ -181,6 +181,15 @@ export class UserNotificationSettingModel extends SequelizeModel<UserNotificatio
@Column
myVideoStudioEditionFinished: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingTranscriptionGeneratedForOwner',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoTranscriptionGenerated')
)
@Column
myVideoTranscriptionGenerated: UserNotificationSettingValueType
@ForeignKey(() => UserModel)
@Column
userId: number
@@ -233,6 +242,7 @@ export class UserNotificationSettingModel extends SequelizeModel<UserNotificatio
abuseStateChange: this.abuseStateChange,
newPeerTubeVersion: this.newPeerTubeVersion,
myVideoStudioEditionFinished: this.myVideoStudioEditionFinished,
myVideoTranscriptionGenerated: this.myVideoTranscriptionGenerated,
newPluginVersion: this.newPluginVersion
}
}

View File

@@ -13,6 +13,7 @@ import { ApplicationModel } from '../application/application.js'
import { PluginModel } from '../server/plugin.js'
import { SequelizeModel, throwIfNotValid } from '../shared/index.js'
import { VideoBlacklistModel } from '../video/video-blacklist.js'
import { VideoCaptionModel } from '../video/video-caption.js'
import { VideoCommentModel } from '../video/video-comment.js'
import { VideoImportModel } from '../video/video-import.js'
import { VideoModel } from '../video/video.js'
@@ -260,6 +261,18 @@ export class UserNotificationModel extends SequelizeModel<UserNotificationModel>
})
UserRegistration: Awaited<UserRegistrationModel>
@ForeignKey(() => VideoCaptionModel)
@Column
videoCaptionId: number
@BelongsTo(() => VideoCaptionModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
VideoCaption: Awaited<VideoCaptionModel>
static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
const where = { userId }
@@ -441,6 +454,17 @@ export class UserNotificationModel extends SequelizeModel<UserNotificationModel>
? { id: this.UserRegistration.id, username: this.UserRegistration.username }
: undefined
const videoCaption = this.VideoCaption
? {
id: this.VideoCaption.id,
language: {
id: this.VideoCaption.language,
label: VideoCaptionModel.getLanguageLabel(this.VideoCaption.language)
},
video: this.formatVideo(this.VideoCaption.Video)
}
: undefined
return {
id: this.id,
type: this.type,
@@ -455,6 +479,7 @@ export class UserNotificationModel extends SequelizeModel<UserNotificationModel>
plugin,
peertube,
registration,
videoCaption,
createdAt: this.createdAt.toISOString(),
updatedAt: this.updatedAt.toISOString()
}
@@ -531,4 +556,11 @@ export class UserNotificationModel extends SequelizeModel<UserNotificationModel>
width: a.width
}
}
formatVideoCaption (a: UserNotificationIncludes.ActorImageInclude) {
return {
path: a.getStaticPath(),
width: a.width
}
}
}

View File

@@ -112,10 +112,21 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
return undefined
}
static async insertOrReplaceLanguage (caption: MVideoCaption, transaction: Transaction) {
const existing = await VideoCaptionModel.loadByVideoIdAndLanguage(caption.videoId, caption.language, transaction)
// Delete existing file
if (existing) await existing.destroy({ transaction })
return caption.save({ transaction })
}
// ---------------------------------------------------------------------------
static loadByVideoIdAndLanguage (videoId: string | number, language: string, transaction?: Transaction): Promise<MVideoCaptionVideo> {
const videoInclude = {
model: VideoModel.unscoped(),
attributes: [ 'id', 'remote', 'uuid' ],
attributes: [ 'id', 'name', 'remote', 'uuid', 'url' ],
where: buildWhereIdOrUUID(videoId)
}
@@ -148,13 +159,18 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
return VideoCaptionModel.findOne(query)
}
static async insertOrReplaceLanguage (caption: MVideoCaption, transaction: Transaction) {
const existing = await VideoCaptionModel.loadByVideoIdAndLanguage(caption.videoId, caption.language, transaction)
// ---------------------------------------------------------------------------
// Delete existing file
if (existing) await existing.destroy({ transaction })
static async hasVideoCaption (videoId: number) {
const query = {
where: {
videoId
}
}
return caption.save({ transaction })
const result = await VideoCaptionModel.unscoped().findOne(query)
return !!result
}
static listVideoCaptions (videoId: number, transaction?: Transaction): Promise<MVideoCaptionVideo[]> {
@@ -194,29 +210,16 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
return result
}
// ---------------------------------------------------------------------------
static getLanguageLabel (language: string) {
return VIDEO_LANGUAGES[language] || 'Unknown'
}
static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Transaction) {
const query = {
where: {
videoId
},
transaction
}
return VideoCaptionModel.destroy(query)
}
static generateCaptionName (language: string) {
return `${buildUUID()}-${language}.vtt`
}
isOwned () {
return this.Video.remote === false
}
// ---------------------------------------------------------------------------
toFormattedJSON (this: MVideoCaptionFormattable): VideoCaption {
@@ -240,6 +243,10 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
// ---------------------------------------------------------------------------
isOwned () {
return this.Video.remote === false
}
getCaptionStaticPath (this: MVideoCaptionLanguageUrl) {
return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
}

View File

@@ -1,10 +1,10 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { Op, QueryTypes, Transaction } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, IsInt, Table, Unique, UpdatedAt } from 'sequelize-typescript'
import { forceNumber } from '@peertube/peertube-core-utils'
import { VideoModel } from './video.js'
import { SequelizeModel } from '../shared/sequelize-type.js'
import { VideoModel } from './video.js'
export type VideoJobInfoColumnType = 'pendingMove' | 'pendingTranscode'
export type VideoJobInfoColumnType = 'pendingMove' | 'pendingTranscode' | 'pendingTranscription'
@Table({
tableName: 'videoJobInfo',
@@ -39,6 +39,12 @@ export class VideoJobInfoModel extends SequelizeModel<VideoJobInfoModel> {
@Column
pendingTranscode: number
@AllowNull(false)
@Default(0)
@IsInt
@Column
pendingTranscription: number
@ForeignKey(() => VideoModel)
@Unique
@Column

View File

@@ -16,6 +16,7 @@ import { VideoBlacklistModel } from '../../../models/video/video-blacklist.js'
import { VideoChannelModel } from '../../../models/video/video-channel.js'
import { VideoCommentModel } from '../../../models/video/video-comment.js'
import { VideoImportModel } from '../../../models/video/video-import.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
type Use<K extends keyof UserNotificationModel, M> = PickWith<UserNotificationModel, K, M>
@@ -98,13 +99,17 @@ export module UserNotificationIncludes {
export type UserRegistrationInclude =
Pick<UserRegistrationModel, 'id' | 'username'>
export type VideoCaptionInclude =
Pick<VideoCaptionModel, 'id' | 'language'> &
PickWith<VideoCaptionModel, 'Video', VideoInclude>
}
// ############################################################################
export type MUserNotification =
Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' |
'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application' | 'UserRegistration'>
'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application' | 'UserRegistration' | 'VideoCaption'>
// ############################################################################
@@ -119,4 +124,5 @@ export type UserNotificationModelForApi =
Use<'Plugin', UserNotificationIncludes.PluginInclude> &
Use<'Application', UserNotificationIncludes.ApplicationInclude> &
Use<'Account', UserNotificationIncludes.AccountIncludeActor> &
Use<'UserRegistration', UserNotificationIncludes.UserRegistrationInclude>
Use<'UserRegistration', UserNotificationIncludes.UserRegistrationInclude> &
Use<'VideoCaption', UserNotificationIncludes.VideoCaptionInclude>

View File

@@ -16,7 +16,7 @@ export type MVideoCaptionLanguageUrl =
export type MVideoCaptionVideo =
MVideoCaption &
Use<'Video', Pick<MVideo, 'id' | 'remote' | 'uuid'>>
Use<'Video', Pick<MVideo, 'id' | 'name' | 'remote' | 'uuid' | 'url' | 'getWatchStaticPath'>>
// ############################################################################

View File

@@ -13,7 +13,8 @@
{ "path": "../packages/models" },
{ "path": "../packages/node-utils" },
{ "path": "../packages/server-commands" },
{ "path": "../packages/typescript-utils" }
{ "path": "../packages/typescript-utils" },
{ "path": "../packages/transcription" }
],
"exclude": [
"tests/"