1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 09:49:20 +02:00

Create and inject caption playlist in HLS master

This commit is contained in:
Chocobozzz 2025-04-08 15:26:02 +02:00
parent a7be820abc
commit 6e44e7e29a
No known key found for this signature in database
GPG key ID: 583A612D890159BE
49 changed files with 1368 additions and 401 deletions

View file

@ -1,6 +1,7 @@
import { HttpStatusCode, VideoCaptionGenerate } from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { createLocalCaption, createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
import { createLocalCaption, createTranscriptionTaskIfNeeded, updateHLSMasterOnCaptionChangeIfNeeded } 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'
@ -17,7 +18,6 @@ import {
listVideoCaptionsValidator
} from '../../../middlewares/validators/index.js'
import { VideoCaptionModel } from '../../../models/video/video-caption.js'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
const lTags = loggerTagsFactory('api', 'video-caption')
@ -25,25 +25,25 @@ const reqVideoCaptionAdd = createReqFiles([ 'captionfile' ], MIMETYPES.VIDEO_CAP
const videoCaptionsRouter = express.Router()
videoCaptionsRouter.post('/:videoId/captions/generate',
videoCaptionsRouter.post(
'/:videoId/captions/generate',
authenticate,
asyncMiddleware(generateVideoCaptionValidator),
asyncMiddleware(createGenerateVideoCaption)
)
videoCaptionsRouter.get('/:videoId/captions',
asyncMiddleware(listVideoCaptionsValidator),
asyncMiddleware(listVideoCaptions)
)
videoCaptionsRouter.get('/:videoId/captions', asyncMiddleware(listVideoCaptionsValidator), asyncMiddleware(listVideoCaptions))
videoCaptionsRouter.put('/:videoId/captions/:captionLanguage',
videoCaptionsRouter.put(
'/:videoId/captions/:captionLanguage',
authenticate,
reqVideoCaptionAdd,
asyncMiddleware(addVideoCaptionValidator),
asyncMiddleware(createVideoCaption)
)
videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage',
videoCaptionsRouter.delete(
'/:videoId/captions/:captionLanguage',
authenticate,
asyncMiddleware(deleteVideoCaptionValidator),
asyncRetryTransactionMiddleware(deleteVideoCaption)
@ -89,10 +89,12 @@ async function createVideoCaption (req: express.Request, res: express.Response)
automaticallyGenerated: false
})
if (videoCaption.m3u8Filename) {
await updateHLSMasterOnCaptionChangeIfNeeded(video)
}
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
return federateVideoIfNeeded(video, false, t)
})
return sequelizeTypescript.transaction(t => federateVideoIfNeeded(video, false, t))
})
Hooks.runAction('action:api.video-caption.created', { caption: videoCaption, req, res })
@ -103,12 +105,18 @@ async function createVideoCaption (req: express.Request, res: express.Response)
async function deleteVideoCaption (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const videoCaption = res.locals.videoCaption
const hasM3U8 = !!videoCaption.m3u8Filename
await sequelizeTypescript.transaction(async t => {
await videoCaption.destroy({ transaction: t })
})
// Send video update
await federateVideoIfNeeded(video, false, t)
if (hasM3U8) {
await updateHLSMasterOnCaptionChangeIfNeeded(video)
}
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(t => federateVideoIfNeeded(video, false, t))
})
logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid, lTags(video.uuid))

View file

@ -29,14 +29,16 @@ const lTags = loggerTagsFactory('api', 'video')
const videoSourceRouter = express.Router()
videoSourceRouter.get('/:id/source',
videoSourceRouter.get(
'/:id/source',
openapiOperationDoc({ operationId: 'getVideoSource' }),
authenticate,
asyncMiddleware(videoSourceGetLatestValidator),
getVideoLatestSource
)
videoSourceRouter.delete('/:id/source/file',
videoSourceRouter.delete(
'/:id/source/file',
openapiOperationDoc({ operationId: 'deleteVideoSourceFile' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
@ -211,6 +213,6 @@ async function removeOldFiles (options: {
}
for (const playlist of playlists) {
await video.removeStreamingPlaylistFiles(playlist)
await video.removeAllStreamingPlaylistFiles({ playlist })
}
}

View file

@ -104,5 +104,5 @@ async function servePrivateM3U8 (req: express.Request, res: express.Response) {
? injectQueryToPlaylistUrls(playlistContent, buildReinjectVideoFileTokenQuery(req, filename.endsWith('master.m3u8')))
: playlistContent
return res.set('content-type', 'application/vnd.apple.mpegurl').send(transformedContent).end()
return res.set('content-type', 'application/x-mpegurl; charset=utf-8').send(transformedContent).end()
}

View file

@ -4,8 +4,8 @@ import { Transform } from 'stream'
import { MVideoCaption } from '@server/types/models/index.js'
import { pipelinePromise } from './core-utils.js'
async function moveAndProcessCaptionFile (physicalFile: { filename?: string, path: string }, videoCaption: MVideoCaption) {
const destination = videoCaption.getFSPath()
export async function moveAndProcessCaptionFile (physicalFile: { filename?: string, path: string }, videoCaption: MVideoCaption) {
const destination = videoCaption.getFSFilePath()
// Convert this srt file to vtt
if (physicalFile.path.endsWith('.srt')) {
@ -22,20 +22,14 @@ async function moveAndProcessCaptionFile (physicalFile: { filename?: string, pat
// ---------------------------------------------------------------------------
export {
moveAndProcessCaptionFile
}
// ---------------------------------------------------------------------------
async function convertSrtToVtt (source: string, destination: string) {
const fixVTT = new Transform({
transform: (chunk, _encoding, cb) => {
let block: string = chunk.toString()
block = block.replace(/(\d\d:\d\d:\d\d)(\s)/g, '$1.000$2')
.replace(/(\d\d:\d\d:\d\d),(\d)(\s)/g, '$1.00$2$3')
.replace(/(\d\d:\d\d:\d\d),(\d\d)(\s)/g, '$1.0$2$3')
.replace(/(\d\d:\d\d:\d\d),(\d)(\s)/g, '$1.00$2$3')
.replace(/(\d\d:\d\d:\d\d),(\d\d)(\s)/g, '$1.0$2$3')
return cb(undefined, block)
}

View file

@ -1,4 +1,6 @@
import { arrayify } from '@peertube/peertube-core-utils'
import {
ActivityCaptionUrlObject,
ActivityPubStoryboard,
ActivityTrackerUrlObject,
ActivityVideoFileMetadataUrlObject,
@ -96,14 +98,14 @@ export function sanitizeAndCheckVideoTorrentObject (video: VideoObject) {
export function isRemoteVideoUrlValid (url: any) {
return url.type === 'Link' &&
// Video file link
(
MIMETYPES.AP_VIDEO.MIMETYPE_EXT[url.mediaType] &&
isActivityPubUrlValid(url.href) &&
validator.default.isInt(url.height + '', { min: 0 }) &&
validator.default.isInt(url.size + '', { min: 0 }) &&
(!url.fps || validator.default.isInt(url.fps + '', { min: -1 }))
) ||
// Video file link
(
MIMETYPES.AP_VIDEO.MIMETYPE_EXT[url.mediaType] &&
isActivityPubUrlValid(url.href) &&
validator.default.isInt(url.height + '', { min: 0 }) &&
validator.default.isInt(url.size + '', { min: 0 }) &&
(!url.fps || validator.default.isInt(url.fps + '', { min: -1 }))
) ||
// Torrent link
(
MIMETYPES.AP_TORRENT.MIMETYPE_EXT[url.mediaType] &&
@ -139,6 +141,13 @@ export function isAPVideoTrackerUrlObject (url: any): url is ActivityTrackerUrlO
isActivityPubUrlValid(url.href)
}
export function isAPCaptionUrlObject (url: any): url is ActivityCaptionUrlObject {
return url &&
url.type === 'Link' &&
(url.mediaType === 'text/vtt' || url.mediaType === 'application/x-mpegURL') &&
isActivityPubUrlValid(url.href)
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
@ -157,7 +166,21 @@ function setValidRemoteCaptions (video: VideoObject) {
if (Array.isArray(video.subtitleLanguage) === false) return false
video.subtitleLanguage = video.subtitleLanguage.filter(caption => {
if (!isActivityPubUrlValid(caption.url)) caption.url = null
if (typeof caption.url === 'string') {
if (isActivityPubUrlValid(caption.url)) {
caption.url = [
{
type: 'Link',
href: caption.url,
mediaType: 'text/vtt'
}
]
} else {
caption.url = []
}
} else {
caption.url = arrayify(caption.url).filter(u => isAPCaptionUrlObject(u))
}
return isRemoteStringIdentifierValid(caption)
})

View file

@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
// ---------------------------------------------------------------------------
export const LAST_MIGRATION_VERSION = 885
export const LAST_MIGRATION_VERSION = 890
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,34 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
const { transaction } = utils
{
await utils.queryInterface.addColumn('videoCaption', 'm3u8Filename', {
type: Sequelize.STRING,
defaultValue: null,
allowNull: true
}, { transaction })
}
{
await utils.queryInterface.addColumn('videoCaption', 'm3u8Url', {
type: Sequelize.STRING,
defaultValue: null,
allowNull: true
}, { transaction })
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
down,
up
}

View file

@ -1,5 +1,6 @@
import { arrayify, maxBy, minBy } from '@peertube/peertube-core-utils'
import {
ActivityCaptionUrlObject,
ActivityHashTagObject,
ActivityMagnetUrlObject,
ActivityPlaylistSegmentHashesObject,
@ -65,11 +66,11 @@ export function getFileAttributesFromUrl (
for (const fileUrl of fileUrls) {
// Fetch associated metadata url, if any
const metadata = urls.filter(isAPVideoFileUrlMetadataObject)
.find(u => {
return u.height === fileUrl.height &&
u.fps === fileUrl.fps &&
u.rel.includes(fileUrl.mediaType)
})
.find(u => {
return u.height === fileUrl.height &&
u.fps === fileUrl.fps &&
u.rel.includes(fileUrl.mediaType)
})
const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
const resolution = fileUrl.height
@ -204,13 +205,23 @@ export function getLiveAttributesFromObject (video: MVideoId, videoObject: Video
}
export function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
return videoObject.subtitleLanguage.map(c => ({
videoId: video.id,
filename: VideoCaptionModel.generateCaptionName(c.identifier),
language: c.identifier,
automaticallyGenerated: c.automaticallyGenerated === true,
fileUrl: c.url
}))
return videoObject.subtitleLanguage.map(c => {
// This field is sanitized in validators
// TODO: Remove as in v8
const url = c.url as (ActivityCaptionUrlObject | ActivityPlaylistUrlObject)[]
const filename = VideoCaptionModel.generateCaptionName(c.identifier)
return {
videoId: video.id,
filename,
language: c.identifier,
automaticallyGenerated: c.automaticallyGenerated === true,
fileUrl: url.find(u => u.mediaType === 'text/vtt')?.href,
m3u8Filename: VideoCaptionModel.generateM3U8Filename(filename),
m3u8Url: url.find(u => u.mediaType === 'application/x-mpegURL')?.href
} as Partial<AttributesOnly<VideoCaptionModel>>
})
}
export function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) {

View file

@ -6,8 +6,7 @@ import { VideoModel } from '../../models/video/video.js'
import { VideoCaptionModel } from '../../models/video/video-caption.js'
import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js'
class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache <string> {
class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache<string> {
private static instance: VideoCaptionsSimpleFileCache
private constructor () {
@ -23,7 +22,7 @@ class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache <string> {
if (!videoCaption) return undefined
if (videoCaption.isOwned()) {
return { isOwned: true, path: videoCaption.getFSPath() }
return { isOwned: true, path: videoCaption.getFSFilePath() }
}
return this.loadRemoteFile(filename)

View file

@ -2,7 +2,8 @@ import { sortBy, uniqify, uuidRegex } from '@peertube/peertube-core-utils'
import { ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
import { FileStorage, VideoResolution } from '@peertube/peertube-models'
import { sha256 } from '@peertube/peertube-node-utils'
import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo, MVideoCaption } from '@server/types/models/index.js'
import { ensureDir, move, outputJSON, remove } from 'fs-extra/esm'
import { open, readFile, stat, writeFile } from 'fs/promises'
import flatten from 'lodash-es/flatten.js'
@ -18,7 +19,7 @@ import { sequelizeTypescript } from '../initializers/database.js'
import { VideoFileModel } from '../models/video/video-file.js'
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist.js'
import { storeHLSFileFromContent } from './object-storage/index.js'
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHlsResolutionPlaylistFilename } from './paths.js'
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHLSResolutionPlaylistFilename } from './paths.js'
import { VideoPathManager } from './video-path-manager.js'
const lTags = loggerTagsFactory('hls')
@ -65,19 +66,31 @@ export async function updateM3U8AndShaPlaylist (video: MVideo, playlist: MStream
// Avoid concurrency issues when updating streaming playlist files
const playlistFilesQueue = new PQueue({ concurrency: 1 })
export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
return playlistFilesQueue.add(async () => {
const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
const captions = await VideoCaptionModel.listVideoCaptions(video.id)
const extMedia: string[] = []
const extMediaAudio: string[] = []
const extMediaSubtitle: string[] = []
const extStreamInfo: string[] = []
let separatedAudioCodec: string
const splitAudioAndVideo = playlist.hasAudioAndVideoSplitted()
for (const caption of captions) {
if (!caption.m3u8Filename) continue
extMediaSubtitle.push(
`#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subtitles",` +
`NAME="${VideoCaptionModel.getLanguageLabel(caption.language)}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,` +
`LANGUAGE="${caption.language}",URI="${caption.m3u8Filename}"`
)
}
// Sort to have the audio resolution first (if it exists)
for (const file of sortBy(playlist.VideoFiles, 'resolution')) {
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
const playlistFilename = getHLSResolutionPlaylistFilename(file.filename)
await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
const probe = await ffprobePromise(videoFilePath)
@ -103,9 +116,8 @@ export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingP
line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
if (splitAudioAndVideo) {
line += `,AUDIO="audio"`
}
if (splitAudioAndVideo) line += `,AUDIO="audio"`
if (extMediaSubtitle.length !== 0) line += `,SUBTITLES="subtitles"`
// Don't include audio only resolution as a regular "video" resolution
// Some player may use it automatically and so the user would not have a video stream
@ -114,12 +126,12 @@ export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingP
extStreamInfo.push(line)
extStreamInfo.push(playlistFilename)
} else if (splitAudioAndVideo) {
extMedia.push(`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Audio",AUTOSELECT=YES,DEFAULT=YES,URI="${playlistFilename}"`)
extMediaAudio.push(`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Audio",AUTOSELECT=YES,DEFAULT=YES,URI="${playlistFilename}"`)
}
})
}
const masterPlaylists = [ '#EXTM3U', '#EXT-X-VERSION:3', '', ...extMedia, '', ...extStreamInfo ]
const masterPlaylists = [ '#EXTM3U', '#EXT-X-VERSION:3', '', ...extMediaSubtitle, '', ...extMediaAudio, '', ...extStreamInfo ]
if (playlist.playlistFilename) {
await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename)
@ -129,7 +141,12 @@ export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingP
const masterPlaylistContent = masterPlaylists.join('\n') + '\n'
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
playlist.playlistUrl = await storeHLSFileFromContent(playlist, playlist.playlistFilename, masterPlaylistContent)
playlist.playlistUrl = await storeHLSFileFromContent({
playlist,
pathOrFilename: playlist.playlistFilename,
content: masterPlaylistContent,
contentType: 'application/x-mpegurl; charset=utf-8'
})
logger.info(`Updated master playlist file of video ${video.uuid} to object storage ${playlist.playlistUrl}`, lTags(video.uuid))
} else {
@ -145,7 +162,7 @@ export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingP
// ---------------------------------------------------------------------------
export function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
return playlistFilesQueue.add(async () => {
const json: { [filename: string]: { [range: string]: string } } = {}
@ -157,7 +174,6 @@ export function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingP
const fileWithPlaylist = file.withVideoOrPlaylist(playlist)
await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => {
return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => {
const playlistContent = await readFile(resolutionPlaylistPath)
const ranges = getRangesFromPlaylist(playlistContent.toString())
@ -183,7 +199,12 @@ export function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingP
playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive)
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
playlist.segmentsSha256Url = await storeHLSFileFromContent(playlist, playlist.segmentsSha256Filename, JSON.stringify(json))
playlist.segmentsSha256Url = await storeHLSFileFromContent({
playlist,
pathOrFilename: playlist.segmentsSha256Filename,
content: JSON.stringify(json),
contentType: 'application/json; charset=utf-8'
})
} else {
const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename)
await outputJSON(outputPath, json)
@ -232,7 +253,7 @@ export function downloadPlaylistSegments (playlistUrl: string, destinationDir: s
await doRequestAndSaveToFile(fileUrl, destPath, { bodyKBLimit: remainingBodyKBLimit, timeout: REQUEST_TIMEOUTS.REDUNDANCY })
const { size } = await stat(destPath)
remainingBodyKBLimit -= (size / 1000)
remainingBodyKBLimit -= size / 1000
logger.debug('Downloaded HLS playlist file %s with %d kB remained limit.', fileUrl, Math.floor(remainingBodyKBLimit))
}
@ -287,6 +308,18 @@ export function injectQueryToPlaylistUrls (content: string, queryString: string)
return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString)
}
// ---------------------------------------------------------------------------
export function buildCaptionM3U8Content (options: {
video: MVideo
caption: MVideoCaption
}) {
const { video, caption } = options
return `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${video.duration}\n#EXT-X-MEDIA-SEQUENCE:0\n` +
`#EXTINF:${video.duration},\n${caption.getFileUrl(video)}\n#EXT-X-ENDLIST\n`
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------

View file

@ -13,7 +13,8 @@ import {
removeOriginalFileObjectStorage,
removeWebVideoObjectStorage
} from '@server/lib/object-storage/index.js'
import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths.js'
import { getHLSDirectory, getHLSResolutionPlaylistFilename } from '@server/lib/paths.js'
import { updateHLSMasterOnCaptionChange, upsertCaptionPlaylistOnFS } from '@server/lib/video-captions.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { moveToFailedMoveToFileSystemState, moveToNextState } from '@server/lib/video-state.js'
import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.js'
@ -115,7 +116,7 @@ async function moveHLSFiles (video: MVideoWithAllFiles) {
if (file.storage === FileStorage.FILE_SYSTEM) continue
// Resolution playlist
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
const playlistFilename = getHLSResolutionPlaylistFilename(file.filename)
await makeHLSFileAvailable(playlistWithVideo, playlistFilename, join(getHLSDirectory(video), playlistFilename))
await makeHLSFileAvailable(playlistWithVideo, file.filename, join(getHLSDirectory(video), file.filename))
@ -152,21 +153,47 @@ async function onVideoFileMoved (options: {
// ---------------------------------------------------------------------------
async function moveCaptionFiles (captions: MVideoCaption[]) {
async function moveCaptionFiles (captions: MVideoCaption[], hls: MStreamingPlaylistVideo) {
let hlsUpdated = false
for (const caption of captions) {
if (caption.storage === FileStorage.FILE_SYSTEM) continue
if (caption.storage === FileStorage.OBJECT_STORAGE) {
const oldFileUrl = caption.fileUrl
await makeCaptionFileAvailable(caption.filename, caption.getFSPath())
await makeCaptionFileAvailable(caption.filename, caption.getFSFilePath())
const oldFileUrl = caption.fileUrl
// Assign new values before building the m3u8 file
caption.fileUrl = null
caption.storage = FileStorage.FILE_SYSTEM
caption.fileUrl = null
caption.storage = FileStorage.FILE_SYSTEM
await caption.save()
await caption.save()
logger.debug('Removing caption file %s because it\'s now on file system', oldFileUrl, lTagsBase())
logger.debug('Removing caption file %s because it\'s now on file system', oldFileUrl, lTagsBase())
await removeCaptionObjectStorage(caption)
}
await removeCaptionObjectStorage(caption)
if (hls && (!caption.m3u8Filename || caption.m3u8Url)) {
hlsUpdated = true
const oldM3U8Url = caption.m3u8Url
const oldM3U8Filename = caption.m3u8Filename
// Caption link has been updated, so we must also update the HLS caption playlist
caption.m3u8Filename = await upsertCaptionPlaylistOnFS(caption, hls.Video)
caption.m3u8Url = null
await caption.save()
if (oldM3U8Url) {
logger.debug(`Removing video caption playlist file ${oldM3U8Url} because it's now on file system`, lTagsBase())
await removeHLSFileObjectStorageByFilename(hls, oldM3U8Filename)
}
}
}
if (hlsUpdated) {
await updateHLSMasterOnCaptionChange(hls.Video, hls)
}
}

View file

@ -2,8 +2,16 @@ import { FileStorage, isMoveCaptionPayload, isMoveVideoStoragePayload, MoveStora
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { updateTorrentMetadata } from '@server/helpers/webtorrent.js'
import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants.js'
import { storeHLSFileFromFilename, storeOriginalVideoFile, storeVideoCaption, storeWebVideoFile } from '@server/lib/object-storage/index.js'
import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths.js'
import { buildCaptionM3U8Content } from '@server/lib/hls.js'
import {
storeHLSFileFromContent,
storeHLSFileFromFilename,
storeOriginalVideoFile,
storeVideoCaption,
storeWebVideoFile
} from '@server/lib/object-storage/index.js'
import { getHLSDirectory, getHLSResolutionPlaylistFilename } from '@server/lib/paths.js'
import { updateHLSMasterOnCaptionChange } from '@server/lib/video-captions.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { moveToFailedMoveToObjectStorageState, moveToNextState } from '@server/lib/video-state.js'
import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.js'
@ -13,6 +21,7 @@ import { remove } from 'fs-extra/esm'
import { join } from 'path'
import { moveCaptionToStorageJob } from './shared/move-caption.js'
import { moveVideoToStorageJob, onMoveVideoToStorageFailure } from './shared/move-video.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
const lTagsBase = loggerTagsFactory('move-object-storage')
@ -84,20 +93,50 @@ async function moveVideoSourceFile (source: MVideoSource) {
// ---------------------------------------------------------------------------
async function moveCaptionFiles (captions: MVideoCaption[]) {
async function moveCaptionFiles (captions: MVideoCaption[], hls: MStreamingPlaylistVideo) {
let hlsUpdated = false
for (const caption of captions) {
if (caption.storage !== FileStorage.FILE_SYSTEM) continue
if (caption.storage === FileStorage.FILE_SYSTEM) {
const captionPath = caption.getFSFilePath()
const captionPath = caption.getFSPath()
const fileUrl = await storeVideoCaption(captionPath, caption.filename)
// Assign new values before building the m3u8 file
caption.fileUrl = await storeVideoCaption(captionPath, caption.filename)
caption.storage = FileStorage.OBJECT_STORAGE
caption.storage = FileStorage.OBJECT_STORAGE
caption.fileUrl = fileUrl
await caption.save()
await caption.save()
logger.debug(`Removing video caption file ${captionPath} because it's now on object storage`, lTagsBase())
logger.debug(`Removing video caption file ${captionPath} because it's now on object storage`, lTagsBase())
await remove(captionPath)
}
await remove(captionPath)
if (hls && (!caption.m3u8Filename || !caption.m3u8Url)) {
hlsUpdated = true
const m3u8PathToRemove = caption.getFSM3U8Path(hls.Video)
// Caption link has been updated, so we must also update the HLS caption playlist
const content = buildCaptionM3U8Content({ video: hls.Video, caption })
caption.m3u8Filename = VideoCaptionModel.generateM3U8Filename(caption.filename)
caption.m3u8Url = await storeHLSFileFromContent({
playlist: hls,
pathOrFilename: caption.m3u8Filename,
content,
contentType: 'application/vnd.apple.mpegurl; charset=utf-8'
})
await caption.save()
if (m3u8PathToRemove) {
logger.debug(`Removing video caption playlist file ${m3u8PathToRemove} because it's now on object storage`, lTagsBase())
await remove(m3u8PathToRemove)
}
}
}
if (hlsUpdated) {
await updateHLSMasterOnCaptionChange(hls.Video, hls)
}
}
@ -122,7 +161,7 @@ async function moveHLSFiles (video: MVideoWithAllFiles) {
if (file.storage !== FileStorage.FILE_SYSTEM) continue
// Resolution playlist
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
const playlistFilename = getHLSResolutionPlaylistFilename(file.filename)
await storeHLSFileFromFilename(playlistWithVideo, playlistFilename)
// Resolution fragmented file

View file

@ -4,15 +4,16 @@ import { sequelizeTypescript } from '@server/initializers/database.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoCaption } from '@server/types/models/index.js'
import { MStreamingPlaylistVideoUUID, MVideoCaption } from '@server/types/models/index.js'
export async function moveCaptionToStorageJob (options: {
jobId: string
captionId: number
loggerTags: (number | string)[]
moveCaptionFiles: (captions: MVideoCaption[]) => Promise<void>
moveCaptionFiles: (captions: MVideoCaption[], hls: MStreamingPlaylistVideoUUID) => Promise<void>
}) {
const {
jobId,
@ -32,8 +33,10 @@ export async function moveCaptionToStorageJob (options: {
const fileMutexReleaser = await VideoPathManager.Instance.lockFiles(caption.Video.uuid)
const hls = await VideoStreamingPlaylistModel.loadHLSByVideoWithVideo(caption.videoId)
try {
await moveCaptionFiles([ caption ])
await moveCaptionFiles([ caption ], hls)
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {

View file

@ -4,7 +4,7 @@ import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoCaption, MVideoWithAllFiles } from '@server/types/models/index.js'
import { MStreamingPlaylistVideoUUID, MVideoCaption, MVideoWithAllFiles } from '@server/types/models/index.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
export async function moveVideoToStorageJob (options: {
@ -15,7 +15,7 @@ export async function moveVideoToStorageJob (options: {
moveWebVideoFiles: (video: MVideoWithAllFiles) => Promise<void>
moveHLSFiles: (video: MVideoWithAllFiles) => Promise<void>
moveVideoSourceFile: (source: MVideoSource) => Promise<void>
moveCaptionFiles: (captions: MVideoCaption[]) => Promise<void>
moveCaptionFiles: (captions: MVideoCaption[], hls: MStreamingPlaylistVideoUUID) => Promise<void>
moveToFailedState: (video: MVideoWithAllFiles) => Promise<void>
doAfterLastMove: (video: MVideoWithAllFiles) => Promise<void>
@ -68,9 +68,10 @@ export async function moveVideoToStorageJob (options: {
const captions = await VideoCaptionModel.listVideoCaptions(video.id)
if (captions.length !== 0) {
logger.debug(`Moving captions of ${video.uuid}.`, lTags)
logger.debug(`Moving ${captions.length} captions of ${video.uuid}.`, lTags)
await moveCaptionFiles(captions)
const hls = video.getHLSPlaylist()
await moveCaptionFiles(captions, hls)
}
const pendingMove = await VideoJobInfoModel.decrease(video.uuid, 'pendingMove')

View file

@ -56,7 +56,6 @@ const config = {
const lTags = loggerTagsFactory('live')
class LiveManager {
private static instance: LiveManager
private readonly muxingSessions = new Map<string, MuxingSession>()
@ -274,13 +273,16 @@ class LiveManager {
if (this.videoSessions.has(video.uuid)) {
logger.warn(
'Video %s has already a live session %s. Refusing stream %s.',
video.uuid, this.videoSessions.get(video.uuid), streamKey, lTags(sessionId, video.uuid)
video.uuid,
this.videoSessions.get(video.uuid),
streamKey,
lTags(sessionId, video.uuid)
)
return this.abortSession(sessionId)
}
// Cleanup old potential live (could happen with a permanent live)
const oldStreamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
const oldStreamingPlaylist = await VideoStreamingPlaylistModel.loadHLSByVideo(video.id)
if (oldStreamingPlaylist) {
if (!videoLive.permanentLive) throw new Error('Found previous session in a non permanent live: ' + video.uuid)
@ -316,7 +318,9 @@ class LiveManager {
if (!hasAudio && !hasVideo) {
logger.warn(
'Not audio and video streams were found for video %s. Refusing stream %s.',
video.uuid, streamKey, lTags(sessionId, video.uuid)
video.uuid,
streamKey,
lTags(sessionId, video.uuid)
)
this.videoSessions.delete(video.uuid)
@ -325,7 +329,12 @@ class LiveManager {
logger.info(
'%s probing took %d ms (bitrate: %d, fps: %d, resolution: %d)',
inputLocalUrl, Date.now() - now, bitrate, fps, resolution, lTags(sessionId, video.uuid)
inputLocalUrl,
Date.now() - now,
bitrate,
fps,
resolution,
lTags(sessionId, video.uuid)
)
const allResolutions = await Hooks.wrapObject(
@ -337,7 +346,9 @@ class LiveManager {
if (!hasAudio && allResolutions.length === 1 && allResolutions[0] === VideoResolution.H_NOVIDEO) {
logger.warn(
'Cannot stream live to audio only because no video stream is available for video %s. Refusing stream %s.',
video.uuid, streamKey, lTags(sessionId, video.uuid)
video.uuid,
streamKey,
lTags(sessionId, video.uuid)
)
this.videoSessions.delete(video.uuid)
@ -345,7 +356,8 @@ class LiveManager {
}
logger.info(
'Handling live video of original resolution %d.', resolution,
'Handling live video of original resolution %d.',
resolution,
{ allResolutions, ...lTags(sessionId, video.uuid) }
)
@ -426,7 +438,8 @@ class LiveManager {
muxingSession.on('bad-socket-health', ({ videoUUID }) => {
logger.error(
'Too much data in client socket stream (ffmpeg is too slow to transcode the video).' +
' Stopping session of video %s.', videoUUID,
' Stopping session of video %s.',
videoUUID,
localLTags
)

View file

@ -51,16 +51,17 @@ interface MuxingSessionEvents {
declare interface MuxingSession {
on<U extends keyof MuxingSessionEvents>(
event: U, listener: MuxingSessionEvents[U]
event: U,
listener: MuxingSessionEvents[U]
): this
emit<U extends keyof MuxingSessionEvents>(
event: U, ...args: Parameters<MuxingSessionEvents[U]>
event: U,
...args: Parameters<MuxingSessionEvents[U]>
): boolean
}
class MuxingSession extends EventEmitter {
private transcodingWrapper: AbstractTranscodingWrapper
private readonly context: any
@ -222,7 +223,14 @@ class MuxingSession extends EventEmitter {
logger.debug('Uploading live master playlist on object storage for %s', this.videoUUID, { masterContent, ...this.lTags() })
const url = await storeHLSFileFromContent(this.streamingPlaylist, this.streamingPlaylist.playlistFilename, masterContent)
const url = await storeHLSFileFromContent(
{
playlist: this.streamingPlaylist,
pathOrFilename: this.streamingPlaylist.playlistFilename,
content: masterContent,
contentType: 'application/x-mpegurl; charset=utf-8'
}
)
this.streamingPlaylist.playlistUrl = url
}
@ -405,18 +413,25 @@ class MuxingSession extends EventEmitter {
}
const queue = this.objectStorageSendQueues.get(m3u8Path)
await queue.add(() => storeHLSFileFromContent(this.streamingPlaylist, m3u8Path, filteredPlaylistContent))
await queue.add(() =>
storeHLSFileFromContent({
playlist: this.streamingPlaylist,
pathOrFilename: m3u8Path,
content: filteredPlaylistContent,
contentType: 'application/x-mpegurl; charset=utf-8'
})
)
} catch (err) {
logger.error('Cannot store in object storage m3u8 file %s', m3u8Path, { err, ...this.lTags() })
}
}
private onTranscodingError () {
this.emit('transcoding-error', ({ videoUUID: this.videoUUID }))
this.emit('transcoding-error', { videoUUID: this.videoUUID })
}
private onTranscodedEnded () {
this.emit('transcoding-end', ({ videoUUID: this.videoUUID }))
this.emit('transcoding-end', { videoUUID: this.videoUUID })
logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', this.inputLocalUrl, this.lTags())
@ -433,7 +448,8 @@ class MuxingSession extends EventEmitter {
})
.catch(err => {
logger.error(
'Cannot close watchers of %s or process remaining hash segments.', this.outDirectory,
'Cannot close watchers of %s or process remaining hash segments.',
this.outDirectory,
{ err, ...this.lTags() }
)
})
@ -482,7 +498,7 @@ class MuxingSession extends EventEmitter {
}
private async createLivePlaylist (): Promise<MStreamingPlaylistVideo> {
const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(this.videoLive.Video)
const { playlist } = await VideoStreamingPlaylistModel.loadOrGenerate(this.videoLive.Video)
playlist.playlistFilename = generateHLSMasterPlaylistFilename(true)
playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(true)

View file

@ -1,11 +1,11 @@
import { MStreamingPlaylistVideoUUID } from '@server/types/models/index.js'
import { join } from 'path'
import { MStreamingPlaylistVideo } from '@server/types/models/index.js'
export function generateHLSObjectStorageKey (playlist: MStreamingPlaylistVideo, filename: string) {
export function generateHLSObjectStorageKey (playlist: MStreamingPlaylistVideoUUID, filename: string) {
return join(generateHLSObjectBaseStorageKey(playlist), filename)
}
export function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideo) {
export function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideoUUID) {
return join(playlist.getStringType(), playlist.Video.uuid)
}

View file

@ -1,6 +1,6 @@
import { logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile } from '@server/types/models/index.js'
import { MStreamingPlaylistVideo, MStreamingPlaylistVideoUUID, MVideo, MVideoCaption, MVideoFile } from '@server/types/models/index.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import { basename, join } from 'path'
import { getHLSDirectory } from '../paths.js'
@ -50,12 +50,22 @@ export function storeHLSFileFromPath (playlist: MStreamingPlaylistVideo, path: s
})
}
export function storeHLSFileFromContent (playlist: MStreamingPlaylistVideo, pathOrFilename: string, content: string) {
export function storeHLSFileFromContent (
options: {
playlist: MStreamingPlaylistVideo
pathOrFilename: string
content: string
contentType: string
}
) {
const { playlist, pathOrFilename, content, contentType } = options
return storeContent({
content,
objectStorageKey: generateHLSObjectStorageKey(playlist, basename(pathOrFilename)),
bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS,
isPrivate: playlist.Video.hasPrivateStaticPath()
isPrivate: playlist.Video.hasPrivateStaticPath(),
contentType
})
}
@ -113,15 +123,15 @@ export async function updateHLSFilesACL (playlist: MStreamingPlaylistVideo) {
// ---------------------------------------------------------------------------
export function removeHLSObjectStorage (playlist: MStreamingPlaylistVideo) {
export function removeHLSObjectStorage (playlist: MStreamingPlaylistVideoUUID) {
return removePrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
}
export function removeHLSFileObjectStorageByFilename (playlist: MStreamingPlaylistVideo, filename: string) {
export function removeHLSFileObjectStorageByFilename (playlist: MStreamingPlaylistVideoUUID, filename: string) {
return removeObject(generateHLSObjectStorageKey(playlist, filename), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
}
export function removeHLSFileObjectStorageByPath (playlist: MStreamingPlaylistVideo, path: string) {
export function removeHLSFileObjectStorageByPath (playlist: MStreamingPlaylistVideoUUID, path: string) {
return removeObject(generateHLSObjectStorageKey(playlist, basename(path)), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
}
@ -149,7 +159,7 @@ export function removeCaptionObjectStorage (videoCaption: MVideoCaption) {
// ---------------------------------------------------------------------------
export async function makeHLSFileAvailable (playlist: MStreamingPlaylistVideo, filename: string, destination: string) {
export async function makeHLSFileAvailable (playlist: MStreamingPlaylistVideoUUID, filename: string, destination: string) {
const key = generateHLSObjectStorageKey(playlist, filename)
logger.info('Fetching HLS file %s from object storage to %s.', key, destination, lTags())

View file

@ -1,4 +1,5 @@
import { join } from 'path'
import { removeFragmentedMP4Ext } from '@peertube/peertube-core-utils'
import { buildUUID } from '@peertube/peertube-node-utils'
import { CONFIG } from '@server/initializers/config.js'
import { DIRECTORIES, VIDEO_LIVE } from '@server/initializers/constants.js'
import {
@ -8,10 +9,10 @@ import {
MUserImport,
MVideo,
MVideoFile,
MVideoPrivacy,
MVideoUUID
} from '@server/types/models/index.js'
import { removeFragmentedMP4Ext } from '@peertube/peertube-core-utils'
import { buildUUID } from '@peertube/peertube-node-utils'
import { join } from 'path'
import { isVideoInPrivateDirectory } from './video-privacy.js'
// ################## Video file name ##################
@ -34,7 +35,7 @@ export function getLiveReplayBaseDirectory (video: MVideo) {
return join(getLiveDirectory(video), VIDEO_LIVE.REPLAY_DIRECTORY)
}
export function getHLSDirectory (video: MVideo) {
export function getHLSDirectory (video: MVideoPrivacy) {
if (isVideoInPrivateDirectory(video.privacy)) {
return join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, video.uuid)
}
@ -46,8 +47,7 @@ export function getHLSRedundancyDirectory (video: MVideoUUID) {
return join(DIRECTORIES.HLS_REDUNDANCY, video.uuid)
}
export function getHlsResolutionPlaylistFilename (videoFilename: string) {
// Video file name already contain resolution
export function getHLSResolutionPlaylistFilename (videoFilename: string) {
return removeFragmentedMP4Ext(videoFilename) + '.m3u8'
}

View file

@ -12,10 +12,11 @@ import { CONFIG } from '../../initializers/config.js'
import { VideoFileModel } from '../../models/video/video-file.js'
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist.js'
import { renameVideoFileInPlaylist, updateM3U8AndShaPlaylist } from '../hls.js'
import { generateHLSVideoFilename, getHlsResolutionPlaylistFilename } from '../paths.js'
import { generateHLSVideoFilename, getHLSResolutionPlaylistFilename } from '../paths.js'
import { buildNewFile } from '../video-file.js'
import { VideoPathManager } from '../video-path-manager.js'
import { buildFFmpegVOD } from './shared/index.js'
import { createAllCaptionPlaylistsOnFSIfNeeded } from '../video-captions.js'
// Concat TS segments from a live video to a fragmented mp4 HLS playlist
export async function generateHlsPlaylistResolutionFromTS (options: {
@ -75,7 +76,7 @@ export async function onHLSVideoFileTranscoding (options: {
const { video, videoOutputPath, m3u8OutputPath, filesLockedInParent = false } = options
// Create or update the playlist
const playlist = await retryTransactionWrapper(() => {
const { playlist, generated: playlistGenerated } = await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async transaction => {
return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction)
})
@ -97,7 +98,7 @@ export async function onHLSVideoFileTranscoding (options: {
// Move playlist file
const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(
video,
getHlsResolutionPlaylistFilename(newVideoFile.filename)
getHLSResolutionPlaylistFilename(newVideoFile.filename)
)
await move(m3u8OutputPath, resolutionPlaylistPath, { overwrite: true })
@ -127,6 +128,10 @@ export async function onHLSVideoFileTranscoding (options: {
const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined)
if (playlistGenerated) {
await createAllCaptionPlaylistsOnFSIfNeeded(video)
}
await updateM3U8AndShaPlaylist(video, playlist)
return { resolutionPlaylistPath, videoFile: savedVideoFile }
@ -180,7 +185,7 @@ async function generateHlsPlaylistCommon (options: {
const videoFilename = generateHLSVideoFilename(resolution)
const videoOutputPath = join(videoTranscodedBasePath, videoFilename)
const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename)
const resolutionPlaylistFilename = getHLSResolutionPlaylistFilename(videoFilename)
const m3u8OutputPath = join(videoTranscodedBasePath, resolutionPlaylistFilename)
const transcodeOptions: HLSTranscodeOptions | HLSFromTSTranscodeOptions = {

View file

@ -23,11 +23,14 @@ import { VideoModel } from '@server/models/video/video.js'
import {
MStreamingPlaylistFiles,
MThumbnail,
MVideo, MVideoAP, MVideoCaption,
MVideo,
MVideoAP,
MVideoCaption,
MVideoCaptionLanguageUrl,
MVideoChapter,
MVideoFile,
MVideoFullLight, MVideoLiveWithSetting,
MVideoFullLight,
MVideoLiveWithSetting,
MVideoPassword
} from '@server/types/models/index.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
@ -37,11 +40,12 @@ import { extname, join } from 'path'
import { PassThrough, Readable } from 'stream'
import { AbstractUserExporter, ExportResult } from './abstract-user-exporter.js'
export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
constructor (private readonly options: ConstructorParameters<typeof AbstractUserExporter<VideoExportJSON>>[0] & {
withVideoFiles: boolean
}) {
export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
constructor (
private readonly options: ConstructorParameters<typeof AbstractUserExporter<VideoExportJSON>>[0] & {
withVideoFiles: boolean
}
) {
super(options)
}
@ -89,10 +93,10 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
const live = video.isLive
? await VideoLiveModel.loadByVideoIdWithSettings(videoId)
: undefined;
: undefined
// We already have captions, so we can set it to the video object
(video as any).VideoCaptions = captions
;(video as any).VideoCaptions = captions
// Then fetch more attributes for AP serialization
const videoAP = await video.lightAPToFullAP(undefined)
@ -320,7 +324,7 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
const relativePathsFromJSON = {
videoFile: null as string,
thumbnail: null as string,
captions: {} as { [ lang: string ]: string }
captions: {} as { [lang: string]: string }
}
if (this.options.withVideoFiles) {
@ -333,9 +337,10 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
archivePath: videoPath,
// Prefer using original file if possible
readStreamFactory: () => source?.keptOriginalFilename
? this.generateVideoSourceReadStream(source)
: this.generateVideoFileReadStream({ video, videoFile, separatedAudioFile })
readStreamFactory: () =>
source?.keptOriginalFilename
? this.generateVideoSourceReadStream(source)
: this.generateVideoFileReadStream({ video, videoFile, separatedAudioFile })
})
relativePathsFromJSON.videoFile = join(this.relativeStaticDirPath, videoPath)
@ -407,7 +412,7 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
private async generateCaptionReadStream (caption: MVideoCaption): Promise<Readable> {
if (caption.storage === FileStorage.FILE_SYSTEM) {
return createReadStream(caption.getFSPath())
return createReadStream(caption.getFSFilePath())
}
const { stream } = await getCaptionReadStream({ filename: caption.filename, rangeHeader: undefined })

View file

@ -37,7 +37,7 @@ import { LocalVideoCreator, ThumbnailOptions } from '@server/lib/local-video-cre
import { isLocalVideoFileAccepted } from '@server/lib/moderation.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { isUserQuotaValid } from '@server/lib/user.js'
import { createLocalCaption } from '@server/lib/video-captions.js'
import { createLocalCaption, updateHLSMasterOnCaptionChange } from '@server/lib/video-captions.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { VideoModel } from '@server/models/video/video.js'
@ -49,12 +49,33 @@ import { AbstractUserImporter } from './abstract-user-importer.js'
const lTags = loggerTagsFactory('user-import')
type ImportObject = VideoExportJSON['videos'][0]
type SanitizedObject = Pick<ImportObject, 'name' | 'duration' | 'channel' | 'privacy' | 'archiveFiles' | 'captions' | 'category' |
'licence' | 'language' | 'description' | 'support' | 'nsfw' | 'isLive' | 'commentsPolicy' | 'downloadEnabled' | 'waitTranscoding' |
'originallyPublishedAt' | 'tags' | 'live' | 'passwords' | 'source' | 'chapters'>
export class VideosImporter extends AbstractUserImporter <VideoExportJSON, ImportObject, SanitizedObject> {
type SanitizedObject = Pick<
ImportObject,
| 'name'
| 'duration'
| 'channel'
| 'privacy'
| 'archiveFiles'
| 'captions'
| 'category'
| 'licence'
| 'language'
| 'description'
| 'support'
| 'nsfw'
| 'isLive'
| 'commentsPolicy'
| 'downloadEnabled'
| 'waitTranscoding'
| 'originallyPublishedAt'
| 'tags'
| 'live'
| 'passwords'
| 'source'
| 'chapters'
>
export class VideosImporter extends AbstractUserImporter<VideoExportJSON, ImportObject, SanitizedObject> {
protected getImportObjects (json: VideoExportJSON) {
return json.videos
}
@ -257,6 +278,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
private async importCaptions (video: MVideoFullLight, videoImportData: SanitizedObject) {
const captionPaths: string[] = []
let updateHLS = false
for (const captionImport of videoImportData.captions) {
const relativeFilePath = videoImportData.archiveFiles?.captions?.[captionImport.language]
@ -270,7 +292,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
if (!await this.isFileValidOrLog(absoluteFilePath, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)) continue
await createLocalCaption({
const caption = await createLocalCaption({
video,
language: captionImport.language,
path: absoluteFilePath,
@ -278,6 +300,12 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
})
captionPaths.push(absoluteFilePath)
if (caption.m3u8Filename) updateHLS = true
}
if (updateHLS && video.getHLSPlaylist()) {
await updateHLSMasterOnCaptionChange(video, video.getHLSPlaylist())
}
return captionPaths

View file

@ -3,23 +3,26 @@ 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 { retryTransactionWrapper } from '@server/helpers/database-utils.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 { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideo, MVideoCaption, MVideoFullLight, MVideoUUID, MVideoUrl } from '@server/types/models/index.js'
import { MStreamingPlaylist, MVideo, MVideoCaption, MVideoFullLight, MVideoUUID, MVideoUrl } from '@server/types/models/index.js'
import { MutexInterface } from 'async-mutex'
import { ensureDir, remove } from 'fs-extra/esm'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { federateVideoIfNeeded } from './activitypub/videos/federate.js'
import { buildCaptionM3U8Content, updateM3U8AndShaPlaylist } from './hls.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'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
const lTags = loggerTagsFactory('video-caption')
@ -41,6 +44,13 @@ export async function createLocalCaption (options: {
await moveAndProcessCaptionFile({ path }, videoCaption)
const hls = await VideoStreamingPlaylistModel.loadHLSByVideo(video.id)
// If object storage is enabled, the move to object storage job will upload the playlist on the fly
videoCaption.m3u8Filename = hls && !CONFIG.OBJECT_STORAGE.ENABLED
? await upsertCaptionPlaylistOnFS(videoCaption, video)
: null
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(t => {
return VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
@ -56,6 +66,41 @@ export async function createLocalCaption (options: {
return Object.assign(videoCaption, { Video: video })
}
// ---------------------------------------------------------------------------
export async function createAllCaptionPlaylistsOnFSIfNeeded (video: MVideo) {
const captions = await VideoCaptionModel.listVideoCaptions(video.id)
for (const caption of captions) {
if (caption.m3u8Filename) continue
try {
caption.m3u8Filename = await upsertCaptionPlaylistOnFS(caption, video)
await caption.save()
} catch (err) {
logger.error(
`Cannot create caption playlist ${caption.filename} (${caption.language}) of video ${video.uuid}`,
{ ...lTags(video.uuid), err }
)
}
}
}
export async function updateHLSMasterOnCaptionChangeIfNeeded (video: MVideo) {
const hls = await VideoStreamingPlaylistModel.loadHLSByVideo(video.id)
if (!hls) return
return updateHLSMasterOnCaptionChange(video, hls)
}
export async function updateHLSMasterOnCaptionChange (video: MVideo, hls: MStreamingPlaylist) {
logger.debug(`Updating HLS master playlist of video ${video.uuid} after caption change`, lTags(video.uuid))
await updateM3U8AndShaPlaylist(video, hls)
}
// ---------------------------------------------------------------------------
export async function createTranscriptionTaskIfNeeded (video: MVideoUUID & MVideoUrl) {
if (CONFIG.VIDEO_TRANSCRIPTION.ENABLED !== true) return
@ -186,6 +231,10 @@ export async function onTranscriptionEnded (options: {
automaticallyGenerated: true
})
if (caption.m3u8Filename) {
await updateHLSMasterOnCaptionChangeIfNeeded(video)
}
await sequelizeTypescript.transaction(async t => {
await federateVideoIfNeeded(video, false, t)
})
@ -194,3 +243,15 @@ export async function onTranscriptionEnded (options: {
logger.info(`Transcription ended for ${video.uuid}`, lTags(video.uuid, ...customLTags))
}
export async function upsertCaptionPlaylistOnFS (caption: MVideoCaption, video: MVideo) {
const m3u8Filename = VideoCaptionModel.generateM3U8Filename(caption.filename)
const m3u8Destination = VideoPathManager.Instance.getFSHLSOutputPath(video, m3u8Filename)
logger.debug(`Creating caption playlist ${m3u8Destination} of video ${video.uuid}`, lTags(video.uuid))
const content = buildCaptionM3U8Content({ video, caption })
await writeFile(m3u8Destination, content, 'utf8')
return m3u8Filename
}

View file

@ -90,7 +90,7 @@ export async function removeHLSPlaylist (video: MVideoWithAllFiles) {
const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
await video.removeStreamingPlaylistFiles(hls)
await video.removeAllStreamingPlaylistFiles({ playlist: hls })
await hls.destroy()
video.VideoStreamingPlaylists = video.VideoStreamingPlaylists.filter(p => p.id !== hls.id)

View file

@ -11,23 +11,23 @@ import {
MVideoFile,
MVideoFileStreamingPlaylistVideo,
MVideoFileVideo,
MVideoPrivacy,
MVideoWithFile
} from '@server/types/models/index.js'
import { Mutex } from 'async-mutex'
import { remove } from 'fs-extra/esm'
import { extname, join } from 'path'
import { makeHLSFileAvailable, makeWebVideoFileAvailable } from './object-storage/index.js'
import { getHLSDirectory, getHlsResolutionPlaylistFilename } from './paths.js'
import { getHLSDirectory, getHLSResolutionPlaylistFilename } from './paths.js'
import { isVideoInPrivateDirectory } from './video-privacy.js'
type MakeAvailableCB <T> = (path: string) => Awaitable<T>
type MakeAvailableMultipleCB <T> = (paths: string[]) => Awaitable<T>
type MakeAvailableCB<T> = (path: string) => Awaitable<T>
type MakeAvailableMultipleCB<T> = (paths: string[]) => Awaitable<T>
type MakeAvailableCreateMethod = { method: () => Awaitable<string>, clean: boolean }
const lTags = loggerTagsFactory('video-path-manager')
class VideoPathManager {
private static instance: VideoPathManager
// Key is a video UUID
@ -35,7 +35,7 @@ class VideoPathManager {
private constructor () {}
getFSHLSOutputPath (video: MVideo, filename?: string) {
getFSHLSOutputPath (video: MVideoPrivacy, filename?: string) {
const base = getHLSDirectory(video)
if (!filename) return base
@ -62,7 +62,7 @@ class VideoPathManager {
// ---------------------------------------------------------------------------
async makeAvailableVideoFiles <T> (videoFiles: (MVideoFileVideo | MVideoFileStreamingPlaylistVideo)[], cb: MakeAvailableMultipleCB<T>) {
async makeAvailableVideoFiles<T> (videoFiles: (MVideoFileVideo | MVideoFileStreamingPlaylistVideo)[], cb: MakeAvailableMultipleCB<T>) {
const createMethods: MakeAvailableCreateMethod[] = []
for (const videoFile of videoFiles) {
@ -95,11 +95,11 @@ class VideoPathManager {
return this.makeAvailableFactory({ createMethods, cbContext: cb })
}
async makeAvailableVideoFile <T> (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
async makeAvailableVideoFile<T> (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
return this.makeAvailableVideoFiles([ videoFile ], paths => cb(paths[0]))
}
async makeAvailableMaxQualityFiles <T> (
async makeAvailableMaxQualityFiles<T> (
video: MVideoWithFile,
cb: (options: { videoPath: string, separatedAudioPath: string }) => Awaitable<T>
) {
@ -115,8 +115,8 @@ class VideoPathManager {
// ---------------------------------------------------------------------------
async makeAvailableResolutionPlaylistFile <T> (videoFile: MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
const filename = getHlsResolutionPlaylistFilename(videoFile.filename)
async makeAvailableResolutionPlaylistFile<T> (videoFile: MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
const filename = getHLSResolutionPlaylistFilename(videoFile.filename)
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
return this.makeAvailableFactory({
@ -142,7 +142,7 @@ class VideoPathManager {
})
}
async makeAvailablePlaylistFile <T> (playlist: MStreamingPlaylistVideo, filename: string, cb: MakeAvailableCB<T>) {
async makeAvailablePlaylistFile<T> (playlist: MStreamingPlaylistVideo, filename: string, cb: MakeAvailableCB<T>) {
if (playlist.storage === FileStorage.FILE_SYSTEM) {
return this.makeAvailableFactory({
createMethods: [
@ -189,7 +189,7 @@ class VideoPathManager {
logger.debug('Released lockfiles of %s.', videoUUID, lTags(videoUUID))
}
private async makeAvailableFactory <T> (options: {
private async makeAvailableFactory<T> (options: {
createMethods: MakeAvailableCreateMethod[]
cbContext: MakeAvailableMultipleCB<T>
}) {

View file

@ -20,7 +20,8 @@ import {
CreatedAt,
DataType,
ForeignKey,
Is, Scopes,
Is,
Scopes,
Table,
UpdatedAt
} from 'sequelize-typescript'
@ -57,7 +58,6 @@ export enum ScopeNames {
]
}
}))
@Table({
tableName: 'videoRedundancy',
indexes: [
@ -77,7 +77,6 @@ export enum ScopeNames {
]
})
export class VideoRedundancyModel extends SequelizeModel<VideoRedundancyModel> {
@CreatedAt
createdAt: Date
@ -134,8 +133,8 @@ export class VideoRedundancyModel extends SequelizeModel<VideoRedundancyModel> {
const videoUUID = videoStreamingPlaylist.Video.uuid
logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
videoStreamingPlaylist.Video.removeStreamingPlaylistFiles(videoStreamingPlaylist, true)
.catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
videoStreamingPlaylist.Video.removeAllStreamingPlaylistFiles({ playlist: videoStreamingPlaylist, isRedundancy: true })
.catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
return undefined
}
@ -295,7 +294,7 @@ export class VideoRedundancyModel extends SequelizeModel<VideoRedundancyModel> {
}
return VideoRedundancyModel.findOne(query)
.then(r => !!r)
.then(r => !!r)
}
static async getVideoSample (p: Promise<VideoModel[]>) {
@ -503,7 +502,7 @@ export class VideoRedundancyModel extends SequelizeModel<VideoRedundancyModel> {
'(' +
'SELECT "videoId" FROM "videoStreamingPlaylist" ' +
'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id' +
')'
')'
)
}
}
@ -516,12 +515,12 @@ export class VideoRedundancyModel extends SequelizeModel<VideoRedundancyModel> {
const sql = `WITH "tmp" AS ` +
`(` +
`SELECT "videoStreamingFile"."size" AS "videoStreamingFileSize", "videoStreamingPlaylist"."videoId" AS "videoStreamingVideoId"` +
`FROM "videoRedundancy" AS "videoRedundancy" ` +
`LEFT JOIN "videoStreamingPlaylist" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ` +
`LEFT JOIN "videoFile" AS "videoStreamingFile" ` +
`ON "videoStreamingPlaylist"."id" = "videoStreamingFile"."videoStreamingPlaylistId" ` +
`WHERE "videoRedundancy"."strategy" = :strategy AND "videoRedundancy"."actorId" = :actorId` +
`SELECT "videoStreamingFile"."size" AS "videoStreamingFileSize", "videoStreamingPlaylist"."videoId" AS "videoStreamingVideoId"` +
`FROM "videoRedundancy" AS "videoRedundancy" ` +
`LEFT JOIN "videoStreamingPlaylist" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ` +
`LEFT JOIN "videoFile" AS "videoStreamingFile" ` +
`ON "videoStreamingPlaylist"."id" = "videoStreamingFile"."videoStreamingPlaylistId" ` +
`WHERE "videoRedundancy"."strategy" = :strategy AND "videoRedundancy"."actorId" = :actorId` +
`) ` +
`SELECT ` +
`COALESCE(SUM("videoStreamingFileSize"), '0') AS "totalUsed", ` +
@ -604,7 +603,7 @@ export class VideoRedundancyModel extends SequelizeModel<VideoRedundancyModel> {
`SELECT "videoStreamingPlaylist"."videoId" AS "videoId" FROM "videoRedundancy" ` +
`INNER JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoRedundancy"."videoStreamingPlaylistId" ` +
`WHERE "videoRedundancy"."actorId" = ${peertubeActor.id} ` +
')'
')'
)
return {

View file

@ -12,7 +12,7 @@ import {
import { uuidToShort } from '@peertube/peertube-node-utils'
import { generateMagnetUri } from '@server/helpers/webtorrent.js'
import { tracer } from '@server/lib/opentelemetry/tracing.js'
import { getHlsResolutionPlaylistFilename } from '@server/lib/paths.js'
import { getHLSResolutionPlaylistFilename } from '@server/lib/paths.js'
import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js'
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
import { isArray } from '../../../helpers/custom-validators/misc.js'
@ -277,7 +277,7 @@ export function videoFilesModelToFormattedJSON (
hasVideo: videoFile.hasVideo(),
playlistUrl: includePlaylistUrl === true
? getHlsResolutionPlaylistFilename(fileUrl)
? getHLSResolutionPlaylistFilename(fileUrl)
: undefined,
storage: video.remote

View file

@ -1,14 +1,19 @@
import { removeVTTExt } from '@peertube/peertube-core-utils'
import { FileStorage, type FileStorageType, VideoCaption, VideoCaptionObject } from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { getObjectStoragePublicFileUrl } from '@server/lib/object-storage/urls.js'
import { removeCaptionObjectStorage } from '@server/lib/object-storage/videos.js'
import { removeCaptionObjectStorage, removeHLSFileObjectStorageByFilename } from '@server/lib/object-storage/videos.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import {
MVideo,
MVideoCaption,
MVideoCaptionFilename,
MVideoCaptionFormattable,
MVideoCaptionLanguageUrl,
MVideoCaptionUrl,
MVideoCaptionVideo,
MVideoOwned
MVideoOwned,
MVideoPrivacy
} from '@server/types/models/index.js'
import { remove } from 'fs-extra/esm'
import { join } from 'path'
@ -22,7 +27,8 @@ import {
DataType,
Default,
ForeignKey,
Is, Scopes,
Is,
Scopes,
Table,
UpdatedAt
} from 'sequelize-typescript'
@ -31,13 +37,14 @@ import { logger } from '../../helpers/logger.js'
import { CONFIG } from '../../initializers/config.js'
import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants.js'
import { SequelizeModel, buildWhereIdOrUUID, doesExist, throwIfNotValid } from '../shared/index.js'
import { VideoStreamingPlaylistModel } from './video-streaming-playlist.js'
import { VideoModel } from './video.js'
export enum ScopeNames {
CAPTION_WITH_VIDEO = 'CAPTION_WITH_VIDEO'
}
const videoAttributes = [ 'id', 'name', 'remote', 'uuid', 'url', 'state' ]
const videoAttributes = [ 'id', 'name', 'remote', 'uuid', 'url', 'state', 'privacy' ]
@Scopes(() => ({
[ScopeNames.CAPTION_WITH_VIDEO]: {
@ -50,7 +57,6 @@ const videoAttributes = [ 'id', 'name', 'remote', 'uuid', 'url', 'state' ]
]
}
}))
@Table({
tableName: 'videoCaption',
indexes: [
@ -83,6 +89,10 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
@Column
filename: string
@AllowNull(true)
@Column
m3u8Filename: string
@AllowNull(false)
@Default(FileStorage.FILE_SYSTEM)
@Column
@ -92,6 +102,10 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
@Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
fileUrl: string
@AllowNull(true)
@Column
m3u8Url: string
@AllowNull(false)
@Column
automaticallyGenerated: boolean
@ -117,11 +131,8 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
if (instance.isOwned()) {
logger.info('Removing caption %s.', instance.filename)
try {
await instance.removeCaptionFile()
} catch (err) {
logger.error('Cannot remove caption file %s.', instance.filename)
}
instance.removeAllCaptionFiles()
.catch(err => logger.error('Cannot remove caption file ' + instance.filename, { err }))
}
return undefined
@ -230,7 +241,7 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
}
const captions = await VideoCaptionModel.scope(ScopeNames.CAPTION_WITH_VIDEO).findAll<MVideoCaptionVideo>(query)
const result: { [ id: number ]: MVideoCaptionVideo[] } = {}
const result: { [id: number]: MVideoCaptionVideo[] } = {}
for (const id of videoIds) {
result[id] = []
@ -253,6 +264,10 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
return `${buildUUID()}-${language}.vtt`
}
static generateM3U8Filename (vttFilename: string) {
return removeVTTExt(vttFilename) + '.m3u8'
}
// ---------------------------------------------------------------------------
toFormattedJSON (this: MVideoCaptionFormattable): VideoCaption {
@ -265,9 +280,10 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
captionPath: this.Video.isOwned() && this.fileUrl
? null // On object storage
: this.getCaptionStaticPath(),
: this.getFileStaticPath(),
fileUrl: this.getFileUrl(this.Video),
m3u8Url: this.getM3U8Url(this.Video),
updatedAt: this.updatedAt.toISOString()
}
@ -278,7 +294,22 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
identifier: this.language,
name: VideoCaptionModel.getLanguageLabel(this.language),
automaticallyGenerated: this.automaticallyGenerated,
url: this.getOriginFileUrl(video)
// TODO: Remove break flag in v8
url: process.env.ENABLE_AP_BREAKING_CHANGES === 'true'
? [
{
type: 'Link',
mediaType: 'text/vtt',
href: this.getOriginFileUrl(video)
},
{
type: 'Link',
mediaType: 'application/x-mpegURL',
href: this.getOriginFileUrl(video)
}
]
: this.getOriginFileUrl(video)
}
}
@ -288,33 +319,77 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
return this.Video.remote === false
}
getCaptionStaticPath (this: MVideoCaptionLanguageUrl) {
// ---------------------------------------------------------------------------
getFileStaticPath (this: MVideoCaptionFilename) {
return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
}
getFSPath () {
return join(CONFIG.STORAGE.CAPTIONS_DIR, this.filename)
}
getM3U8StaticPath (this: MVideoCaptionFilename, video: MVideoPrivacy) {
if (!this.m3u8Filename) return null
removeCaptionFile (this: MVideoCaption) {
if (this.storage === FileStorage.OBJECT_STORAGE) {
return removeCaptionObjectStorage(this)
}
return remove(this.getFSPath())
return VideoStreamingPlaylistModel.getPlaylistFileStaticPath(video, this.m3u8Filename)
}
// ---------------------------------------------------------------------------
getFileUrl (this: MVideoCaptionLanguageUrl, video: MVideoOwned) {
getFSFilePath () {
return join(CONFIG.STORAGE.CAPTIONS_DIR, this.filename)
}
getFSM3U8Path (video: MVideoPrivacy) {
if (!this.m3u8Filename) return null
return VideoPathManager.Instance.getFSHLSOutputPath(video, this.m3u8Filename)
}
async removeAllCaptionFiles (this: MVideoCaptionVideo) {
await this.removeCaptionFile()
await this.removeCaptionPlaylist()
}
async removeCaptionFile (this: MVideoCaptionVideo) {
if (this.storage === FileStorage.OBJECT_STORAGE) {
if (this.fileUrl) {
await removeCaptionObjectStorage(this)
}
} else {
await remove(this.getFSFilePath())
}
this.filename = null
this.fileUrl = null
}
async removeCaptionPlaylist (this: MVideoCaptionVideo) {
if (!this.m3u8Filename) return
const hls = await VideoStreamingPlaylistModel.loadHLSByVideoWithVideo(this.videoId)
if (!hls) return
if (this.storage === FileStorage.OBJECT_STORAGE) {
if (this.m3u8Url) {
await removeHLSFileObjectStorageByFilename(hls, this.m3u8Filename)
}
} else {
await remove(this.getFSM3U8Path(this.Video))
}
this.m3u8Filename = null
this.m3u8Url = null
}
// ---------------------------------------------------------------------------
getFileUrl (this: MVideoCaptionUrl, video: MVideoOwned) {
if (video.isOwned() && this.storage === FileStorage.OBJECT_STORAGE) {
return getObjectStoragePublicFileUrl(this.fileUrl, CONFIG.OBJECT_STORAGE.CAPTIONS)
}
return WEBSERVER.URL + this.getCaptionStaticPath()
return WEBSERVER.URL + this.getFileStaticPath()
}
getOriginFileUrl (this: MVideoCaptionLanguageUrl, video: MVideoOwned) {
getOriginFileUrl (this: MVideoCaptionUrl, video: MVideoOwned) {
if (video.isOwned()) return this.getFileUrl(video)
return this.fileUrl
@ -322,6 +397,22 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
// ---------------------------------------------------------------------------
getM3U8Url (this: MVideoCaptionUrl, video: MVideoOwned & MVideoPrivacy) {
if (!this.m3u8Filename) return null
if (video.isOwned()) {
if (this.storage === FileStorage.OBJECT_STORAGE) {
return getObjectStoragePublicFileUrl(this.m3u8Url, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
}
return WEBSERVER.URL + this.getM3U8StaticPath(video)
}
return this.m3u8Url
}
// ---------------------------------------------------------------------------
isEqual (this: MVideoCaption, other: MVideoCaption) {
if (this.fileUrl) return this.fileUrl === other.fileUrl

View file

@ -12,22 +12,18 @@ import { getHLSPrivateFileUrl, getObjectStoragePublicFileUrl } from '@server/lib
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths.js'
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
import { MStreamingPlaylist, MStreamingPlaylistFiles, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js'
import {
MStreamingPlaylist,
MStreamingPlaylistFiles,
MStreamingPlaylistFilesVideo,
MStreamingPlaylistVideo,
MVideo,
MVideoPrivacy
} from '@server/types/models/index.js'
import memoizee from 'memoizee'
import { join } from 'path'
import { Op, Transaction } from 'sequelize'
import {
AllowNull,
BelongsTo,
Column,
CreatedAt,
DataType,
Default,
ForeignKey,
HasMany,
Is, Table,
UpdatedAt
} from 'sequelize-typescript'
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, Is, Table, UpdatedAt } from 'sequelize-typescript'
import { isArrayOf } from '../../helpers/custom-validators/misc.js'
import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos.js'
import {
@ -205,7 +201,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
return VideoStreamingPlaylistModel.findByPk(id, options)
}
static loadHLSPlaylistByVideo (videoId: number, transaction?: Transaction): Promise<MStreamingPlaylist> {
static loadHLSByVideo (videoId: number, transaction?: Transaction): Promise<MStreamingPlaylist> {
const options = {
where: {
type: VideoStreamingPlaylistType.HLS,
@ -217,10 +213,31 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
return VideoStreamingPlaylistModel.findOne(options)
}
static loadHLSByVideoWithVideo (videoId: number, transaction?: Transaction): Promise<MStreamingPlaylistVideo> {
const options = {
where: {
type: VideoStreamingPlaylistType.HLS,
videoId
},
include: [
{
model: VideoModel.unscoped(),
required: true
}
],
transaction
}
return VideoStreamingPlaylistModel.findOne(options)
}
static async loadOrGenerate (video: MVideo, transaction?: Transaction) {
let playlist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id, transaction)
let playlist = await VideoStreamingPlaylistModel.loadHLSByVideo(video.id, transaction)
let generated = false
if (!playlist) {
generated = true
playlist = new VideoStreamingPlaylistModel({
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
type: VideoStreamingPlaylistType.HLS,
@ -234,7 +251,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
await playlist.save({ transaction })
}
return Object.assign(playlist, { Video: video })
return { generated, playlist: Object.assign(playlist, { Video: video }) }
}
static doesOwnedVideoUUIDExist (videoUUID: string, storage: FileStorageType) {
@ -339,19 +356,21 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
return Object.assign(this, { Video: video })
}
private getMasterPlaylistStaticPath (video: MVideo) {
// ---------------------------------------------------------------------------
static getPlaylistFileStaticPath (video: MVideoPrivacy, filename: string) {
if (isVideoInPrivateDirectory(video.privacy)) {
return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.playlistFilename)
return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, filename)
}
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.playlistFilename)
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, filename)
}
private getSha256SegmentsStaticPath (video: MVideo) {
if (isVideoInPrivateDirectory(video.privacy)) {
return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.segmentsSha256Filename)
}
private getMasterPlaylistStaticPath (video: MVideoPrivacy) {
return VideoStreamingPlaylistModel.getPlaylistFileStaticPath(video, this.playlistFilename)
}
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.segmentsSha256Filename)
private getSha256SegmentsStaticPath (video: MVideoPrivacy) {
return VideoStreamingPlaylistModel.getPlaylistFileStaticPath(video, this.segmentsSha256Filename)
}
}

View file

@ -31,7 +31,7 @@ import {
removeWebVideoObjectStorage
} from '@server/lib/object-storage/index.js'
import { tracer } from '@server/lib/opentelemetry/tracing.js'
import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths.js'
import { getHLSDirectory, getHLSRedundancyDirectory, getHLSResolutionPlaylistFilename } from '@server/lib/paths.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
@ -640,7 +640,6 @@ export class VideoModel extends SequelizeModel<VideoModel> {
name: 'videoId',
allowNull: true
},
hooks: true,
onDelete: 'cascade'
})
VideoFiles: Awaited<VideoFileModel>[]
@ -650,7 +649,6 @@ export class VideoModel extends SequelizeModel<VideoModel> {
name: 'videoId',
allowNull: false
},
hooks: true,
onDelete: 'cascade'
})
VideoStreamingPlaylists: Awaited<VideoStreamingPlaylistModel>[]
@ -834,7 +832,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
static async removeFiles (instance: VideoModel, options) {
const tasks: Promise<any>[] = []
logger.info('Removing files of video %s.', instance.url)
logger.info('Removing files of video %s.', instance.url, { toto: new Error().stack })
if (instance.isOwned()) {
if (!Array.isArray(instance.VideoFiles)) {
@ -852,7 +850,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
}
for (const p of instance.VideoStreamingPlaylists) {
tasks.push(instance.removeStreamingPlaylistFiles(p))
// Captions will be automatically deleted
tasks.push(instance.removeAllStreamingPlaylistFiles({ playlist: p, deleteCaptionPlaylists: false }))
}
// Remove source files
@ -1904,7 +1903,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
if (isArray(videoAP.VideoCaptions)) return videoAP.VideoCaptions
return this.$get('VideoCaptions', {
attributes: [ 'filename', 'language', 'fileUrl', 'storage', 'automaticallyGenerated' ],
attributes: [ 'filename', 'language', 'fileUrl', 'storage', 'automaticallyGenerated', 'm3u8Filename', 'm3u8Url' ],
transaction
}) as Promise<MVideoCaptionLanguageUrl[]>
}
@ -1993,47 +1992,76 @@ export class VideoModel extends SequelizeModel<VideoModel> {
return Promise.all(promises)
}
async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
async removeAllStreamingPlaylistFiles (options: {
playlist: MStreamingPlaylist
deleteCaptionPlaylists?: boolean // default true
isRedundancy?: boolean // default false
}) {
const { playlist, deleteCaptionPlaylists = true, isRedundancy = false } = options
const directoryPath = isRedundancy
? getHLSRedundancyDirectory(this)
: getHLSDirectory(this)
try {
await remove(directoryPath)
} catch (err) {
// If it's a live, ffmpeg may have added another file while fs-extra is removing the directory
// So wait a little bit and retry
if (err.code === 'ENOTEMPTY') {
await wait(1000)
const removeDirectory = async () => {
try {
await remove(directoryPath)
} catch (err) {
// If it's a live, ffmpeg may have added another file while fs-extra is removing the directory
// So wait a little bit and retry
if (err.code === 'ENOTEMPTY') {
await wait(1000)
await remove(directoryPath)
return
return
}
throw err
}
throw err
}
if (isRedundancy !== true) {
const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
streamingPlaylistWithFiles.Video = this
if (isRedundancy) {
await removeDirectory()
} else {
if (deleteCaptionPlaylists) {
const captions = await VideoCaptionModel.listVideoCaptions(playlist.videoId)
if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
// Remove playlist files associated to captions
for (const caption of captions) {
try {
await caption.removeCaptionPlaylist()
await caption.save()
} catch (err) {
logger.error(
`Cannot remove caption ${caption.filename} (${caption.language}) playlist files associated to video ${this.name}`,
{ video: this, ...lTags(this.uuid) }
)
}
}
}
await removeDirectory()
const playlistWithFiles = playlist as MStreamingPlaylistFilesVideo
playlistWithFiles.Video = this
if (!Array.isArray(playlistWithFiles.VideoFiles)) {
playlistWithFiles.VideoFiles = await playlistWithFiles.$get('VideoFiles')
}
// Remove physical files and torrents
await Promise.all(
streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
playlistWithFiles.VideoFiles.map(file => file.removeTorrent())
)
if (streamingPlaylist.storage === FileStorage.OBJECT_STORAGE) {
await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
await removeHLSObjectStorage(playlist.withVideo(this))
}
}
logger.debug(
`Removing files associated to streaming playlist of video ${this.url}`,
{ streamingPlaylist, isRedundancy, ...lTags(this.uuid) }
{ playlist, isRedundancy, ...lTags(this.uuid) }
)
}
@ -2042,7 +2070,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
await videoFile.removeTorrent()
await remove(filePath)
const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
const resolutionFilename = getHLSResolutionPlaylistFilename(videoFile.filename)
await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
if (videoFile.storage === FileStorage.OBJECT_STORAGE) {

View file

@ -1,6 +1,6 @@
import { PickWith } from '@peertube/peertube-typescript-utils'
import { VideoCaptionModel } from '../../../models/video/video-caption.js'
import { MVideo, MVideoOwned, MVideoUUID } from './video.js'
import { MVideo, MVideoOwned, MVideoPrivacy } from './video.js'
type Use<K extends keyof VideoCaptionModel, M> = PickWith<VideoCaptionModel, K, M>
@ -11,6 +11,13 @@ export type MVideoCaption = Omit<VideoCaptionModel, 'Video'>
// ############################################################################
export type MVideoCaptionLanguage = Pick<MVideoCaption, 'language'>
export type MVideoCaptionFilename = Pick<MVideoCaption, 'filename' | 'getFileStaticPath' | 'm3u8Filename' | 'getM3U8StaticPath'>
export type MVideoCaptionUrl = Pick<
MVideoCaption,
'filename' | 'getFileStaticPath' | 'storage' | 'fileUrl' | 'm3u8Url' | 'getFileUrl' | 'getM3U8Url' | 'm3u8Filename' | 'getM3U8StaticPath'
>
export type MVideoCaptionLanguageUrl = Pick<
MVideoCaption,
| 'language'
@ -18,15 +25,19 @@ export type MVideoCaptionLanguageUrl = Pick<
| 'storage'
| 'filename'
| 'automaticallyGenerated'
| 'getFileUrl'
| 'getCaptionStaticPath'
| 'm3u8Filename'
| 'm3u8Url'
| 'toActivityPubObject'
| 'getFileUrl'
| 'getFileStaticPath'
| 'getOriginFileUrl'
| 'getM3U8Url'
| 'getM3U8StaticPath'
>
export type MVideoCaptionVideo =
& MVideoCaption
& Use<'Video', Pick<MVideo, 'id' | 'name' | 'remote' | 'uuid' | 'url' | 'state' | 'getWatchStaticPath' | 'isOwned'>>
& Use<'Video', Pick<MVideo, 'id' | 'name' | 'remote' | 'uuid' | 'url' | 'state' | 'getWatchStaticPath' | 'isOwned' | 'privacy'>>
// ############################################################################
@ -35,4 +46,4 @@ export type MVideoCaptionVideo =
export type MVideoCaptionFormattable =
& MVideoCaption
& Pick<MVideoCaption, 'language'>
& Use<'Video', MVideoOwned & MVideoUUID>
& Use<'Video', MVideoOwned & MVideoPrivacy>

View file

@ -1,6 +1,6 @@
import { PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils'
import { VideoStreamingPlaylistModel } from '../../../models/video/video-streaming-playlist.js'
import { MVideo } from './video.js'
import { MVideo, MVideoUUID } from './video.js'
import { MVideoFile } from './video-file.js'
import { MVideoRedundancy, MVideoRedundancyFileUrl } from './video-redundancy.js'
@ -18,6 +18,10 @@ export type MStreamingPlaylistVideo =
& MStreamingPlaylist
& Use<'Video', MVideo>
export type MStreamingPlaylistVideoUUID =
& MStreamingPlaylist
& Use<'Video', MVideoUUID>
export type MStreamingPlaylistFilesVideo =
& MStreamingPlaylist
& Use<'VideoFiles', MVideoFile[]>

View file

@ -61,6 +61,7 @@ export type MVideo = Omit<
export type MVideoId = Pick<MVideo, 'id'>
export type MVideoUrl = Pick<MVideo, 'url'>
export type MVideoUUID = Pick<MVideo, 'uuid'>
export type MVideoPrivacy = Pick<MVideo, 'privacy' | 'uuid'>
export type MVideoImmutable = Pick<MVideo, 'id' | 'url' | 'uuid' | 'remote' | 'isOwned'>
export type MVideoOwned = Pick<MVideo, 'remote' | 'isOwned'>

View file

@ -8,7 +8,7 @@ import { JobQueue } from '@server/lib/job-queue/index.js'
import {
generateHLSMasterPlaylistFilename,
generateHlsSha256SegmentsFilename,
getHlsResolutionPlaylistFilename
getHLSResolutionPlaylistFilename
} from '@server/lib/paths.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
@ -19,7 +19,6 @@ run()
.catch(err => {
console.error(err)
process.exit(-1)
})
async function run () {
@ -52,7 +51,7 @@ async function processVideo (videoId: number) {
console.log(`Renaming HLS playlist files of video ${video.name}.`)
const playlist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
const playlist = await VideoStreamingPlaylistModel.loadHLSByVideo(video.id)
const hlsDirPath = VideoPathManager.Instance.getFSHLSOutputPath(video)
const masterPlaylistPath = join(hlsDirPath, playlist.playlistFilename)
@ -60,7 +59,7 @@ async function processVideo (videoId: number) {
for (const videoFile of hls.VideoFiles) {
const srcName = `${videoFile.resolution}.m3u8`
const dstName = getHlsResolutionPlaylistFilename(videoFile.filename)
const dstName = getHLSResolutionPlaylistFilename(videoFile.filename)
const src = join(hlsDirPath, srcName)
const dst = join(hlsDirPath, dstName)