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:
parent
a7be820abc
commit
6e44e7e29a
49 changed files with 1368 additions and 401 deletions
|
@ -74,7 +74,6 @@ export class RestExtractor {
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildErrorMessage (err: any) {
|
private buildErrorMessage (err: any) {
|
||||||
console.log(err)
|
|
||||||
if (err.error instanceof Error) {
|
if (err.error instanceof Error) {
|
||||||
// A client-side or network error occurred. Handle it accordingly.
|
// A client-side or network error occurred. Handle it accordingly.
|
||||||
const errorMessage = err.error.detail || err.error.title
|
const errorMessage = err.error.detail || err.error.title
|
||||||
|
|
|
@ -22,5 +22,10 @@ export function getRtcConfig (stunServers: string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSameOrigin (current: string, target: string) {
|
export function isSameOrigin (current: string, target: string) {
|
||||||
return new URL(current).origin === new URL(target).origin
|
const currentUrl = new URL(current)
|
||||||
|
const targetUrl = new URL(target)
|
||||||
|
|
||||||
|
if (currentUrl.hostname === 'localhost' && targetUrl.hostname === 'localhost') return true
|
||||||
|
|
||||||
|
return currentUrl.origin === targetUrl.origin
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,12 +6,7 @@ import debug from 'debug'
|
||||||
import { Level } from 'hls.js'
|
import { Level } from 'hls.js'
|
||||||
import type { CoreConfig, StreamConfig } from 'p2p-media-loader-core'
|
import type { CoreConfig, StreamConfig } from 'p2p-media-loader-core'
|
||||||
import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
|
import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
|
||||||
import {
|
import { HLSPluginOptions, P2PMediaLoaderPluginOptions, PeerTubePlayerConstructorOptions, PeerTubePlayerLoadOptions } from '../../types'
|
||||||
HLSPluginOptions,
|
|
||||||
P2PMediaLoaderPluginOptions,
|
|
||||||
PeerTubePlayerConstructorOptions,
|
|
||||||
PeerTubePlayerLoadOptions
|
|
||||||
} from '../../types'
|
|
||||||
import { getRtcConfig, isSameOrigin } from '../common'
|
import { getRtcConfig, isSameOrigin } from '../common'
|
||||||
import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
|
import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
|
||||||
import { SegmentValidator } from '../p2p-media-loader/segment-validator'
|
import { SegmentValidator } from '../p2p-media-loader/segment-validator'
|
||||||
|
@ -19,14 +14,14 @@ import { SegmentValidator } from '../p2p-media-loader/segment-validator'
|
||||||
const debugLogger = debug('peertube:player:hls')
|
const debugLogger = debug('peertube:player:hls')
|
||||||
|
|
||||||
type ConstructorOptions =
|
type ConstructorOptions =
|
||||||
Pick<PeerTubePlayerConstructorOptions, 'pluginsManager' | 'serverUrl' | 'authorizationHeader' | 'stunServers'> &
|
& Pick<PeerTubePlayerConstructorOptions, 'pluginsManager' | 'serverUrl' | 'authorizationHeader' | 'stunServers'>
|
||||||
Pick<PeerTubePlayerLoadOptions, 'videoPassword' | 'requiresUserAuth' | 'videoFileToken' | 'requiresPassword' |
|
& Pick<
|
||||||
'isLive' | 'liveOptions' | 'p2pEnabled' | 'hls'>
|
PeerTubePlayerLoadOptions,
|
||||||
|
'videoPassword' | 'requiresUserAuth' | 'videoFileToken' | 'requiresPassword' | 'isLive' | 'liveOptions' | 'p2pEnabled' | 'hls'
|
||||||
|
>
|
||||||
|
|
||||||
export class HLSOptionsBuilder {
|
export class HLSOptionsBuilder {
|
||||||
|
|
||||||
constructor (private options: ConstructorOptions) {
|
constructor (private options: ConstructorOptions) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPluginOptions () {
|
async getPluginOptions () {
|
||||||
|
|
|
@ -3,3 +3,7 @@ export const uuidRegex = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-
|
||||||
export function removeFragmentedMP4Ext (path: string) {
|
export function removeFragmentedMP4Ext (path: string) {
|
||||||
return path.replace(/-fragmented.mp4$/i, '')
|
return path.replace(/-fragmented.mp4$/i, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeVTTExt (path: string) {
|
||||||
|
return path.replace(/\.vtt$/i, '')
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { AbusePredefinedReasonsString } from '../../moderation/abuse/abuse-reaso
|
||||||
export interface ActivityIdentifierObject {
|
export interface ActivityIdentifierObject {
|
||||||
identifier: string
|
identifier: string
|
||||||
name: string
|
name: string
|
||||||
url?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityIconObject {
|
export interface ActivityIconObject {
|
||||||
|
@ -49,7 +48,7 @@ export type ActivityPlaylistSegmentHashesObject = {
|
||||||
|
|
||||||
export type ActivityVideoFileMetadataUrlObject = {
|
export type ActivityVideoFileMetadataUrlObject = {
|
||||||
type: 'Link'
|
type: 'Link'
|
||||||
rel: [ 'metadata', any ]
|
rel: ['metadata', any]
|
||||||
mediaType: 'application/json'
|
mediaType: 'application/json'
|
||||||
height: number
|
height: number
|
||||||
width: number | null
|
width: number | null
|
||||||
|
@ -57,9 +56,15 @@ export type ActivityVideoFileMetadataUrlObject = {
|
||||||
fps: number
|
fps: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ActivityCaptionUrlObject = {
|
||||||
|
type: 'Link'
|
||||||
|
mediaType: 'text/vtt'
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ActivityTrackerUrlObject = {
|
export type ActivityTrackerUrlObject = {
|
||||||
type: 'Link'
|
type: 'Link'
|
||||||
rel: [ 'tracker', 'websocket' | 'http' ]
|
rel: ['tracker', 'websocket' | 'http']
|
||||||
name: string
|
name: string
|
||||||
href: string
|
href: string
|
||||||
}
|
}
|
||||||
|
@ -118,7 +123,7 @@ export interface ActivityFlagReasonObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActivityTagObject =
|
export type ActivityTagObject =
|
||||||
ActivityPlaylistSegmentHashesObject
|
| ActivityPlaylistSegmentHashesObject
|
||||||
| ActivityStreamingPlaylistInfohashesObject
|
| ActivityStreamingPlaylistInfohashesObject
|
||||||
| ActivityVideoUrlObject
|
| ActivityVideoUrlObject
|
||||||
| ActivityHashTagObject
|
| ActivityHashTagObject
|
||||||
|
@ -128,13 +133,14 @@ export type ActivityTagObject =
|
||||||
| ActivityVideoFileMetadataUrlObject
|
| ActivityVideoFileMetadataUrlObject
|
||||||
|
|
||||||
export type ActivityUrlObject =
|
export type ActivityUrlObject =
|
||||||
ActivityVideoUrlObject
|
| ActivityVideoUrlObject
|
||||||
| ActivityPlaylistUrlObject
|
| ActivityPlaylistUrlObject
|
||||||
| ActivityBitTorrentUrlObject
|
| ActivityBitTorrentUrlObject
|
||||||
| ActivityMagnetUrlObject
|
| ActivityMagnetUrlObject
|
||||||
| ActivityHtmlUrlObject
|
| ActivityHtmlUrlObject
|
||||||
| ActivityVideoFileMetadataUrlObject
|
| ActivityVideoFileMetadataUrlObject
|
||||||
| ActivityTrackerUrlObject
|
| ActivityTrackerUrlObject
|
||||||
|
| ActivityCaptionUrlObject
|
||||||
|
|
||||||
export type ActivityPubAttributedTo = { type: 'Group' | 'Person', id: string } | string
|
export type ActivityPubAttributedTo = { type: 'Group' | 'Person', id: string } | string
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { ActivityIdentifierObject } from './common-objects.js'
|
import { ActivityCaptionUrlObject, ActivityIdentifierObject, ActivityPlaylistUrlObject } from './common-objects.js'
|
||||||
|
|
||||||
export interface VideoCaptionObject extends ActivityIdentifierObject {
|
export interface VideoCaptionObject extends ActivityIdentifierObject {
|
||||||
automaticallyGenerated: boolean
|
automaticallyGenerated: boolean
|
||||||
|
|
||||||
|
url: string | (ActivityCaptionUrlObject | ActivityPlaylistUrlObject)[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ export interface VideoCaption {
|
||||||
captionPath: string
|
captionPath: string
|
||||||
|
|
||||||
fileUrl: string
|
fileUrl: string
|
||||||
|
m3u8Url: string
|
||||||
|
|
||||||
automaticallyGenerated: boolean
|
automaticallyGenerated: boolean
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
import { wait } from '@peertube/peertube-core-utils'
|
import { wait } from '@peertube/peertube-core-utils'
|
||||||
import { hasAudioStream, hasVideoStream } from '@peertube/peertube-ffmpeg'
|
import { hasAudioStream, hasVideoStream } from '@peertube/peertube-ffmpeg'
|
||||||
import {
|
import {
|
||||||
AccountExportJSON, ActivityPubActor,
|
AccountExportJSON,
|
||||||
|
ActivityPubActor,
|
||||||
ActivityPubOrderedCollection,
|
ActivityPubOrderedCollection,
|
||||||
AutoTagPoliciesJSON,
|
AutoTagPoliciesJSON,
|
||||||
BlocklistExportJSON,
|
BlocklistExportJSON,
|
||||||
|
@ -22,7 +23,8 @@ import {
|
||||||
VideoChapterObject,
|
VideoChapterObject,
|
||||||
VideoCommentObject,
|
VideoCommentObject,
|
||||||
VideoCreateResult,
|
VideoCreateResult,
|
||||||
VideoExportJSON, VideoPlaylistCreateResult,
|
VideoExportJSON,
|
||||||
|
VideoPlaylistCreateResult,
|
||||||
VideoPlaylistPrivacy,
|
VideoPlaylistPrivacy,
|
||||||
VideoPlaylistsExportJSON,
|
VideoPlaylistsExportJSON,
|
||||||
VideoPlaylistType,
|
VideoPlaylistType,
|
||||||
|
@ -31,7 +33,9 @@ import {
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
import {
|
import {
|
||||||
cleanupTests, getRedirectionUrl, makeActivityPubRawRequest,
|
cleanupTests,
|
||||||
|
getRedirectionUrl,
|
||||||
|
makeActivityPubRawRequest,
|
||||||
makeRawRequest,
|
makeRawRequest,
|
||||||
ObjectStorageCommand,
|
ObjectStorageCommand,
|
||||||
PeerTubeServer,
|
PeerTubeServer,
|
||||||
|
@ -81,9 +85,9 @@ function runTest (withObjectStorage: boolean) {
|
||||||
|
|
||||||
objectStorage = withObjectStorage
|
objectStorage = withObjectStorage
|
||||||
? new ObjectStorageCommand()
|
? new ObjectStorageCommand()
|
||||||
: undefined;
|
: undefined
|
||||||
|
|
||||||
({
|
;({
|
||||||
rootId,
|
rootId,
|
||||||
noahId,
|
noahId,
|
||||||
remoteRootId,
|
remoteRootId,
|
||||||
|
@ -285,7 +289,11 @@ function runTest (withObjectStorage: boolean) {
|
||||||
// Subtitles
|
// Subtitles
|
||||||
expect(video.subtitleLanguage).to.have.lengthOf(2)
|
expect(video.subtitleLanguage).to.have.lengthOf(2)
|
||||||
for (const subtitle of video.subtitleLanguage) {
|
for (const subtitle of video.subtitleLanguage) {
|
||||||
await checkFileExistsInZIP(zip, subtitle.url, '/activity-pub')
|
const subtitleUrl = typeof subtitle.url === 'string'
|
||||||
|
? subtitle.url
|
||||||
|
: subtitle.url.find(u => u.mediaType === 'text/vtt').href
|
||||||
|
|
||||||
|
await checkFileExistsInZIP(zip, subtitleUrl, '/activity-pub')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chapters
|
// Chapters
|
||||||
|
@ -905,7 +913,6 @@ function runTest (withObjectStorage: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Test user export', function () {
|
describe('Test user export', function () {
|
||||||
|
|
||||||
describe('From filesystem', function () {
|
describe('From filesystem', function () {
|
||||||
runTest(false)
|
runTest(false)
|
||||||
})
|
})
|
||||||
|
|
|
@ -14,12 +14,7 @@ import {
|
||||||
VideoState
|
VideoState
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
import {
|
import { ObjectStorageCommand, PeerTubeServer, cleanupTests, waitJobs } from '@peertube/peertube-server-commands'
|
||||||
ObjectStorageCommand,
|
|
||||||
PeerTubeServer,
|
|
||||||
cleanupTests,
|
|
||||||
waitJobs
|
|
||||||
} from '@peertube/peertube-server-commands'
|
|
||||||
import { testAvatarSize, testImage } from '@tests/shared/checks.js'
|
import { testAvatarSize, testImage } from '@tests/shared/checks.js'
|
||||||
import { prepareImportExportTests } from '@tests/shared/import-export.js'
|
import { prepareImportExportTests } from '@tests/shared/import-export.js'
|
||||||
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
|
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
|
||||||
|
@ -57,9 +52,8 @@ function runTest (withObjectStorage: boolean) {
|
||||||
|
|
||||||
objectStorage = withObjectStorage
|
objectStorage = withObjectStorage
|
||||||
? new ObjectStorageCommand()
|
? new ObjectStorageCommand()
|
||||||
: undefined;
|
: undefined
|
||||||
|
;({
|
||||||
({
|
|
||||||
noahId,
|
noahId,
|
||||||
externalVideo,
|
externalVideo,
|
||||||
noahVideo,
|
noahVideo,
|
||||||
|
@ -122,7 +116,6 @@ function runTest (withObjectStorage: boolean) {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Import process', function () {
|
describe('Import process', function () {
|
||||||
|
|
||||||
it('Should import an archive with video files', async function () {
|
it('Should import an archive with video files', async function () {
|
||||||
this.timeout(240000)
|
this.timeout(240000)
|
||||||
|
|
||||||
|
@ -142,7 +135,6 @@ function runTest (withObjectStorage: boolean) {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Import data', function () {
|
describe('Import data', function () {
|
||||||
|
|
||||||
it('Should have correctly imported blocklist', async function () {
|
it('Should have correctly imported blocklist', async function () {
|
||||||
{
|
{
|
||||||
const { data } = await remoteServer.blocklist.listMyAccountBlocklist({ start: 0, count: 5, token: remoteNoahToken })
|
const { data } = await remoteServer.blocklist.listMyAccountBlocklist({ start: 0, count: 5, token: remoteNoahToken })
|
||||||
|
@ -363,6 +355,9 @@ function runTest (withObjectStorage: boolean) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have correctly imported user videos', async function () {
|
it('Should have correctly imported user videos', async function () {
|
||||||
|
// We observe weird behaviour in the CI, so re-wait jobs here just to be sure
|
||||||
|
await waitJobs([ remoteServer, server ])
|
||||||
|
|
||||||
const { data } = await remoteServer.videos.listMyVideos({ token: remoteNoahToken })
|
const { data } = await remoteServer.videos.listMyVideos({ token: remoteNoahToken })
|
||||||
expect(data).to.have.lengthOf(5)
|
expect(data).to.have.lengthOf(5)
|
||||||
|
|
||||||
|
@ -453,12 +448,24 @@ function runTest (withObjectStorage: boolean) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data: captions } = await remoteServer.captions.list({ videoId: otherVideo.uuid })
|
||||||
|
|
||||||
|
// TODO: merge these functions in v8, caption playlist are not federated before v8
|
||||||
await completeCheckHlsPlaylist({
|
await completeCheckHlsPlaylist({
|
||||||
hlsOnly: false,
|
hlsOnly: false,
|
||||||
servers: [ remoteServer, server ],
|
servers: [ remoteServer ],
|
||||||
videoUUID: otherVideo.uuid,
|
videoUUID: otherVideo.uuid,
|
||||||
objectStorageBaseUrl: objectStorage?.getMockPlaylistBaseUrl(),
|
objectStorageBaseUrl: objectStorage?.getMockPlaylistBaseUrl(),
|
||||||
resolutions: [ 720, 240 ]
|
resolutions: [ 720, 240 ],
|
||||||
|
captions
|
||||||
|
})
|
||||||
|
await completeCheckHlsPlaylist({
|
||||||
|
hlsOnly: false,
|
||||||
|
servers: [ server ],
|
||||||
|
videoUUID: otherVideo.uuid,
|
||||||
|
objectStorageBaseUrl: objectStorage?.getMockPlaylistBaseUrl(),
|
||||||
|
resolutions: [ 720, 240 ],
|
||||||
|
captions: [] // Caption playlist are not federated before v8
|
||||||
})
|
})
|
||||||
|
|
||||||
const source = await remoteServer.videos.getSource({ id: otherVideo.uuid })
|
const source = await remoteServer.videos.getSource({ id: otherVideo.uuid })
|
||||||
|
@ -498,7 +505,6 @@ function runTest (withObjectStorage: boolean) {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Re-import', function () {
|
describe('Re-import', function () {
|
||||||
|
|
||||||
it('Should re-import the same file', async function () {
|
it('Should re-import the same file', async function () {
|
||||||
this.timeout(240000)
|
this.timeout(240000)
|
||||||
|
|
||||||
|
@ -584,7 +590,6 @@ function runTest (withObjectStorage: boolean) {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('After import', function () {
|
describe('After import', function () {
|
||||||
|
|
||||||
it('Should have received an email on finished import', async function () {
|
it('Should have received an email on finished import', async function () {
|
||||||
const email = emails.reverse().find(e => {
|
const email = emails.reverse().find(e => {
|
||||||
return e['to'][0]['address'] === 'noah_remote@example.com' &&
|
return e['to'][0]['address'] === 'noah_remote@example.com' &&
|
||||||
|
@ -616,7 +621,6 @@ function runTest (withObjectStorage: boolean) {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Custom video options included in the export', function () {
|
describe('Custom video options included in the export', function () {
|
||||||
|
|
||||||
async function generateAndExportImport (username: string) {
|
async function generateAndExportImport (username: string) {
|
||||||
const archivePath = join(server.getDirectoryPath('tmp'), `archive${username}.zip`)
|
const archivePath = join(server.getDirectoryPath('tmp'), `archive${username}.zip`)
|
||||||
const fixture = 'video_short1.webm'
|
const fixture = 'video_short1.webm'
|
||||||
|
@ -718,7 +722,6 @@ function runTest (withObjectStorage: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Test user import', function () {
|
describe('Test user import', function () {
|
||||||
|
|
||||||
describe('From filesystem', function () {
|
describe('From filesystem', function () {
|
||||||
runTest(false)
|
runTest(false)
|
||||||
})
|
})
|
||||||
|
|
276
packages/tests/src/api/videos/video-captions-playlist.ts
Normal file
276
packages/tests/src/api/videos/video-captions-playlist.ts
Normal file
|
@ -0,0 +1,276 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
|
import { getHLS } from '@peertube/peertube-core-utils'
|
||||||
|
import { ffprobePromise } from '@peertube/peertube-ffmpeg'
|
||||||
|
import { HttpStatusCode, VideoResolution } from '@peertube/peertube-models'
|
||||||
|
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
|
import {
|
||||||
|
cleanupTests,
|
||||||
|
createMultipleServers,
|
||||||
|
doubleFollow,
|
||||||
|
makeRawRequest,
|
||||||
|
ObjectStorageCommand,
|
||||||
|
PeerTubeServer,
|
||||||
|
setAccessTokensToServers,
|
||||||
|
waitJobs
|
||||||
|
} from '@peertube/peertube-server-commands'
|
||||||
|
import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
|
describe('Test video caption playlist', function () {
|
||||||
|
let servers: PeerTubeServer[]
|
||||||
|
let videoUUID: string
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
servers = await createMultipleServers(2)
|
||||||
|
|
||||||
|
await setAccessTokensToServers(servers)
|
||||||
|
await doubleFollow(servers[0], servers[1])
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await servers[0].config.enableMinimumTranscoding()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function renewVideo () {
|
||||||
|
const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video' } })
|
||||||
|
videoUUID = uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCaptions () {
|
||||||
|
for (const language of [ 'zh', 'fr' ]) {
|
||||||
|
await servers[0].captions.add({
|
||||||
|
language,
|
||||||
|
videoId: videoUUID,
|
||||||
|
fixture: 'subtitle-good.srt'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests (objectStorageBaseUrl?: string) {
|
||||||
|
async function checkPlaylist () {
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: videoUUID })
|
||||||
|
|
||||||
|
await completeCheckHlsPlaylist({
|
||||||
|
servers,
|
||||||
|
videoUUID,
|
||||||
|
hlsOnly: false,
|
||||||
|
hasAudio: true,
|
||||||
|
hasVideo: true,
|
||||||
|
captions,
|
||||||
|
objectStorageBaseUrl,
|
||||||
|
resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ]
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const caption of captions) {
|
||||||
|
// TODO: remove condition when ffmpeg static is not used anymore.
|
||||||
|
// See https://stackoverflow.com/questions/60528501/ffmpeg-segmentation-fault-with-network-stream-source
|
||||||
|
if (!objectStorageBaseUrl) {
|
||||||
|
const { streams } = await ffprobePromise(caption.m3u8Url)
|
||||||
|
expect(streams.find(s => s.codec_name === 'webvtt')).to.exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Should create a caption playlist on a HLS video', async function () {
|
||||||
|
await renewVideo()
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await checkPlaylist()
|
||||||
|
|
||||||
|
await addCaptions()
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await checkPlaylist()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should delete the video and delete caption playlist file', async function () {
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: videoUUID })
|
||||||
|
const m3u8Url = captions[0].m3u8Url
|
||||||
|
await makeRawRequest({ url: m3u8Url, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
await servers[0].videos.remove({ id: videoUUID })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await makeRawRequest({ url: m3u8Url, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should create caption playlists on a web video that has been manually transcoded', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
await servers[0].config.enableMinimumTranscoding({ hls: false })
|
||||||
|
|
||||||
|
await renewVideo()
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await addCaptions()
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: videoUUID })
|
||||||
|
|
||||||
|
for (const caption of captions) {
|
||||||
|
expect(caption.m3u8Url).to.not.exist
|
||||||
|
}
|
||||||
|
|
||||||
|
await servers[0].config.enableMinimumTranscoding()
|
||||||
|
await servers[0].videos.runTranscoding({ transcodingType: 'hls', videoId: videoUUID })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await checkPlaylist()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should delete the caption and delete the caption playlist file', async function () {
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: videoUUID })
|
||||||
|
const zhCaption = captions.find(c => c.language.id === 'zh')
|
||||||
|
|
||||||
|
await makeRawRequest({ url: zhCaption.m3u8Url, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
await servers[0].captions.delete({ videoId: videoUUID, language: 'zh' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await makeRawRequest({ url: zhCaption.m3u8Url, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
|
||||||
|
const video = await servers[0].videos.get({ id: videoUUID })
|
||||||
|
const playlistContent = await servers[0].streamingPlaylists.get({ url: getHLS(video).playlistUrl })
|
||||||
|
expect(playlistContent).to.include('LANGUAGE="fr"')
|
||||||
|
expect(playlistContent).to.not.include('LANGUAGE="zh"')
|
||||||
|
|
||||||
|
await checkPlaylist()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should delete all the captions and delete the caption playlist file', async function () {
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: videoUUID })
|
||||||
|
const frCaption = captions.find(c => c.language.id === 'fr')
|
||||||
|
|
||||||
|
await makeRawRequest({ url: frCaption.m3u8Url, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
await servers[0].captions.delete({ videoId: videoUUID, language: 'fr' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await makeRawRequest({ url: frCaption.m3u8Url, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
|
||||||
|
const video = await servers[0].videos.get({ id: videoUUID })
|
||||||
|
const playlistContent = await servers[0].streamingPlaylists.get({ url: getHLS(video).playlistUrl })
|
||||||
|
expect(playlistContent).to.not.include('LANGUAGE="zh"')
|
||||||
|
|
||||||
|
await checkPlaylist()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should remove all HLS files and remove the caption playlist files', async function () {
|
||||||
|
await renewVideo()
|
||||||
|
await addCaptions()
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: videoUUID })
|
||||||
|
await checkPlaylist()
|
||||||
|
|
||||||
|
await servers[0].videos.removeHLSPlaylist({ videoId: videoUUID })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const caption of captions) {
|
||||||
|
await makeRawRequest({ url: caption.m3u8Url, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should re-add HLS and re-add the caption playlist files', async function () {
|
||||||
|
await servers[0].videos.runTranscoding({ transcodingType: 'hls', videoId: videoUUID })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await checkPlaylist()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('On filesystem', function () {
|
||||||
|
runTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('On object storage', function () {
|
||||||
|
if (areMockObjectStorageTestsDisabled()) return
|
||||||
|
|
||||||
|
const objectStorage = new ObjectStorageCommand()
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
await objectStorage.prepareDefaultMockBuckets()
|
||||||
|
await servers[0].kill()
|
||||||
|
await servers[0].run(objectStorage.getDefaultMockConfig())
|
||||||
|
})
|
||||||
|
|
||||||
|
runTests(objectStorage.getMockPlaylistBaseUrl())
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await objectStorage.cleanupMock()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('With AP federation breaking changes enabled', function () {
|
||||||
|
before(async function () {
|
||||||
|
await servers[0].kill()
|
||||||
|
await servers[0].run({}, { env: { ENABLE_AP_BREAKING_CHANGES: 'true' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should correctly federate captions m3u8 URL', async function () {
|
||||||
|
await renewVideo()
|
||||||
|
await addCaptions()
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const { data: captions } = await server.captions.list({ videoId: videoUUID })
|
||||||
|
|
||||||
|
for (const caption of captions) {
|
||||||
|
expect(caption.fileUrl).to.exist
|
||||||
|
expect(caption.m3u8Url).to.exist
|
||||||
|
|
||||||
|
await makeRawRequest({ url: caption.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await makeRawRequest({ url: caption.m3u8Url, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Without AP federation breaking changes enabled', function () {
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
await servers[0].kill()
|
||||||
|
await servers[0].run()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not federate captions m3u8 URL', async function () {
|
||||||
|
await renewVideo()
|
||||||
|
await addCaptions()
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: videoUUID })
|
||||||
|
|
||||||
|
for (const caption of captions) {
|
||||||
|
expect(caption.fileUrl).to.exist
|
||||||
|
expect(caption.m3u8Url).to.exist
|
||||||
|
|
||||||
|
await makeRawRequest({ url: caption.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await makeRawRequest({ url: caption.m3u8Url, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data: captions } = await servers[1].captions.list({ videoId: videoUUID })
|
||||||
|
|
||||||
|
for (const caption of captions) {
|
||||||
|
expect(caption.fileUrl).to.exist
|
||||||
|
expect(caption.m3u8Url).to.not.exist
|
||||||
|
|
||||||
|
await makeRawRequest({ url: caption.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await cleanupTests(servers)
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { VideoPrivacy } from '@peertube/peertube-models'
|
import { VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
|
||||||
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
import {
|
import {
|
||||||
ObjectStorageCommand,
|
ObjectStorageCommand,
|
||||||
|
@ -15,6 +15,7 @@ import {
|
||||||
waitJobs
|
waitJobs
|
||||||
} from '@peertube/peertube-server-commands'
|
} from '@peertube/peertube-server-commands'
|
||||||
import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
|
import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
|
||||||
|
import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js'
|
||||||
import { checkAutoCaption, checkLanguage, checkNoCaption, getCaptionContent, uploadForTranscription } from '@tests/shared/transcription.js'
|
import { checkAutoCaption, checkLanguage, checkNoCaption, getCaptionContent, uploadForTranscription } from '@tests/shared/transcription.js'
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
@ -39,11 +40,12 @@ describe('Test video transcription', function () {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('Common on filesystem', function () {
|
describe('Common on filesystem', function () {
|
||||||
|
|
||||||
it('Should generate a transcription on request', async function () {
|
it('Should generate a transcription on request', async function () {
|
||||||
this.timeout(360000)
|
this.timeout(360000)
|
||||||
|
|
||||||
await servers[0].config.disableTranscription()
|
await servers[0].config.disableTranscription()
|
||||||
|
await servers[0].config.save()
|
||||||
|
await servers[0].config.enableMinimumTranscoding({ webVideo: false, hls: true })
|
||||||
|
|
||||||
const uuid = await uploadForTranscription(servers[0])
|
const uuid = await uploadForTranscription(servers[0])
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
@ -56,6 +58,22 @@ describe('Test video transcription', function () {
|
||||||
await checkLanguage(servers, uuid, 'en')
|
await checkLanguage(servers, uuid, 'en')
|
||||||
|
|
||||||
await checkAutoCaption({ servers, uuid })
|
await checkAutoCaption({ servers, uuid })
|
||||||
|
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: uuid })
|
||||||
|
expect(captions).to.have.lengthOf(1)
|
||||||
|
|
||||||
|
await completeCheckHlsPlaylist({
|
||||||
|
servers,
|
||||||
|
videoUUID: uuid,
|
||||||
|
hlsOnly: true,
|
||||||
|
hasAudio: true,
|
||||||
|
hasVideo: true,
|
||||||
|
captions,
|
||||||
|
resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ]
|
||||||
|
})
|
||||||
|
|
||||||
|
await servers[0].config.rollback()
|
||||||
|
await servers[0].config.enableTranscription()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should run transcription on upload by default', async function () {
|
it('Should run transcription on upload by default', async function () {
|
||||||
|
@ -260,6 +278,8 @@ describe('Test video transcription', function () {
|
||||||
this.timeout(360000)
|
this.timeout(360000)
|
||||||
|
|
||||||
await servers[0].config.disableTranscription()
|
await servers[0].config.disableTranscription()
|
||||||
|
await servers[0].config.save()
|
||||||
|
await servers[0].config.enableMinimumTranscoding({ webVideo: false, hls: true })
|
||||||
|
|
||||||
const uuid = await uploadForTranscription(servers[0])
|
const uuid = await uploadForTranscription(servers[0])
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
@ -272,6 +292,23 @@ describe('Test video transcription', function () {
|
||||||
await checkLanguage(servers, uuid, 'en')
|
await checkLanguage(servers, uuid, 'en')
|
||||||
|
|
||||||
await checkAutoCaption({ servers, uuid, objectStorageBaseUrl: objectStorage.getMockCaptionFileBaseUrl() })
|
await checkAutoCaption({ servers, uuid, objectStorageBaseUrl: objectStorage.getMockCaptionFileBaseUrl() })
|
||||||
|
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: uuid })
|
||||||
|
expect(captions).to.have.lengthOf(1)
|
||||||
|
|
||||||
|
await completeCheckHlsPlaylist({
|
||||||
|
servers,
|
||||||
|
videoUUID: uuid,
|
||||||
|
hlsOnly: true,
|
||||||
|
hasAudio: true,
|
||||||
|
hasVideo: true,
|
||||||
|
captions,
|
||||||
|
objectStorageBaseUrl: objectStorage.getMockPlaylistBaseUrl(),
|
||||||
|
resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ]
|
||||||
|
})
|
||||||
|
|
||||||
|
await servers[0].config.rollback()
|
||||||
|
await servers[0].config.enableTranscription()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should run transcription on upload by default', async function () {
|
it('Should run transcription on upload by default', async function () {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { getAllFiles } from '@peertube/peertube-core-utils'
|
import { getAllFiles } from '@peertube/peertube-core-utils'
|
||||||
import { HttpStatusCode, VideoDetails } from '@peertube/peertube-models'
|
import { HttpStatusCode, VideoDetails, VideoResolution } from '@peertube/peertube-models'
|
||||||
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
import {
|
import {
|
||||||
ObjectStorageCommand,
|
ObjectStorageCommand,
|
||||||
|
@ -15,6 +15,7 @@ import {
|
||||||
waitJobs
|
waitJobs
|
||||||
} from '@peertube/peertube-server-commands'
|
} from '@peertube/peertube-server-commands'
|
||||||
import { checkDirectoryIsEmpty } from '@tests/shared/directories.js'
|
import { checkDirectoryIsEmpty } from '@tests/shared/directories.js'
|
||||||
|
import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { expectStartWith } from '../shared/checks.js'
|
import { expectStartWith } from '../shared/checks.js'
|
||||||
|
|
||||||
|
@ -81,6 +82,17 @@ async function checkFiles (options: {
|
||||||
|
|
||||||
await makeRawRequest({ url: caption.fileUrl, token: origin.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url: caption.fileUrl, token: origin.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await completeCheckHlsPlaylist({
|
||||||
|
servers: [ origin ],
|
||||||
|
videoUUID: video.uuid,
|
||||||
|
hlsOnly: false,
|
||||||
|
hasAudio: true,
|
||||||
|
hasVideo: true,
|
||||||
|
captions,
|
||||||
|
objectStorageBaseUrl: objectStorage?.getMockPlaylistBaseUrl(),
|
||||||
|
resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,7 +132,6 @@ describe('Test create move video storage job CLI', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('To object storage', function () {
|
describe('To object storage', function () {
|
||||||
|
|
||||||
it('Should move only one file', async function () {
|
it('Should move only one file', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
|
@ -158,6 +169,8 @@ describe('Test create move video storage job CLI', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should not have files on disk anymore', async function () {
|
it('Should not have files on disk anymore', async function () {
|
||||||
|
await checkDirectoryIsEmpty(servers[0], 'captions', [ 'private' ])
|
||||||
|
|
||||||
await checkDirectoryIsEmpty(servers[0], 'web-videos', [ 'private' ])
|
await checkDirectoryIsEmpty(servers[0], 'web-videos', [ 'private' ])
|
||||||
await checkDirectoryIsEmpty(servers[0], join('web-videos', 'private'))
|
await checkDirectoryIsEmpty(servers[0], join('web-videos', 'private'))
|
||||||
|
|
||||||
|
@ -171,9 +184,14 @@ describe('Test create move video storage job CLI', function () {
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
const video = await servers[0].videos.get({ id: uuids[1] })
|
const video = await servers[0].videos.get({ id: uuids[1] })
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: uuids[1] })
|
||||||
|
|
||||||
oldFileUrls = [
|
oldFileUrls = [
|
||||||
...getAllFiles(video).map(f => f.fileUrl),
|
...getAllFiles(video).map(f => f.fileUrl),
|
||||||
|
|
||||||
|
...captions.map(c => c.fileUrl),
|
||||||
|
...captions.map(c => c.m3u8Url),
|
||||||
|
|
||||||
video.streamingPlaylists[0].playlistUrl
|
video.streamingPlaylists[0].playlistUrl
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { wait } from '@peertube/peertube-core-utils'
|
import { wait } from '@peertube/peertube-core-utils'
|
||||||
import { RunnerJobState } from '@peertube/peertube-models'
|
import { RunnerJobState, VideoResolution } from '@peertube/peertube-models'
|
||||||
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
import {
|
import {
|
||||||
ObjectStorageCommand,
|
ObjectStorageCommand,
|
||||||
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '@peertube/peertube-server-commands'
|
} from '@peertube/peertube-server-commands'
|
||||||
import { checkPeerTubeRunnerCacheIsEmpty } from '@tests/shared/directories.js'
|
import { checkPeerTubeRunnerCacheIsEmpty } from '@tests/shared/directories.js'
|
||||||
import { PeerTubeRunnerProcess } from '@tests/shared/peertube-runner-process.js'
|
import { PeerTubeRunnerProcess } from '@tests/shared/peertube-runner-process.js'
|
||||||
|
import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js'
|
||||||
import { checkAutoCaption, checkLanguage, checkNoCaption, uploadForTranscription } from '@tests/shared/transcription.js'
|
import { checkAutoCaption, checkLanguage, checkNoCaption, uploadForTranscription } from '@tests/shared/transcription.js'
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
|
|
||||||
|
@ -42,9 +43,7 @@ describe('Test transcription in peertube-runner program', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Running transcription', function () {
|
describe('Running transcription', function () {
|
||||||
|
|
||||||
describe('Common on filesystem', function () {
|
describe('Common on filesystem', function () {
|
||||||
|
|
||||||
it('Should run transcription on classic file', async function () {
|
it('Should run transcription on classic file', async function () {
|
||||||
this.timeout(360000)
|
this.timeout(360000)
|
||||||
|
|
||||||
|
@ -108,11 +107,30 @@ describe('Test transcription in peertube-runner program', function () {
|
||||||
it('Should run transcription and upload it on object storage', async function () {
|
it('Should run transcription and upload it on object storage', async function () {
|
||||||
this.timeout(360000)
|
this.timeout(360000)
|
||||||
|
|
||||||
|
await servers[0].config.save()
|
||||||
|
await servers[0].config.enableMinimumTranscoding({ webVideo: false, hls: true })
|
||||||
|
|
||||||
const uuid = await uploadForTranscription(servers[0])
|
const uuid = await uploadForTranscription(servers[0])
|
||||||
await waitJobs(servers, { runnerJobs: true, skipFailed: true }) // skipFailed because previous test had a failed runner job
|
await waitJobs(servers, { runnerJobs: true, skipFailed: true }) // skipFailed because previous test had a failed runner job
|
||||||
|
|
||||||
await checkAutoCaption({ servers, uuid, objectStorageBaseUrl: objectStorage.getMockCaptionFileBaseUrl() })
|
await checkAutoCaption({ servers, uuid, objectStorageBaseUrl: objectStorage.getMockCaptionFileBaseUrl() })
|
||||||
await checkLanguage(servers, uuid, 'en')
|
await checkLanguage(servers, uuid, 'en')
|
||||||
|
|
||||||
|
const { data: captions } = await servers[0].captions.list({ videoId: uuid })
|
||||||
|
expect(captions).to.have.lengthOf(1)
|
||||||
|
|
||||||
|
await completeCheckHlsPlaylist({
|
||||||
|
servers,
|
||||||
|
videoUUID: uuid,
|
||||||
|
hlsOnly: true,
|
||||||
|
hasAudio: true,
|
||||||
|
hasVideo: true,
|
||||||
|
captions,
|
||||||
|
objectStorageBaseUrl: objectStorage.getMockPlaylistBaseUrl(),
|
||||||
|
resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ]
|
||||||
|
})
|
||||||
|
|
||||||
|
await servers[0].config.rollback()
|
||||||
})
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
|
@ -121,7 +139,6 @@ describe('Test transcription in peertube-runner program', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When transcription is not enabled in runner', function () {
|
describe('When transcription is not enabled in runner', function () {
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' })
|
await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' })
|
||||||
peertubeRunner.kill()
|
peertubeRunner.kill()
|
||||||
|
@ -148,7 +165,6 @@ describe('Test transcription in peertube-runner program', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Check cleanup', function () {
|
describe('Check cleanup', function () {
|
||||||
|
|
||||||
it('Should have an empty cache directory', async function () {
|
it('Should have an empty cache directory', async function () {
|
||||||
await checkPeerTubeRunnerCacheIsEmpty(peertubeRunner, 'transcription')
|
await checkPeerTubeRunnerCacheIsEmpty(peertubeRunner, 'transcription')
|
||||||
})
|
})
|
||||||
|
|
|
@ -19,7 +19,8 @@ import {
|
||||||
ObjectStorageCommand,
|
ObjectStorageCommand,
|
||||||
PeerTubeServer,
|
PeerTubeServer,
|
||||||
createSingleServer,
|
createSingleServer,
|
||||||
doubleFollow, makeRawRequest,
|
doubleFollow,
|
||||||
|
makeRawRequest,
|
||||||
setAccessTokensToServers,
|
setAccessTokensToServers,
|
||||||
setDefaultVideoChannel,
|
setDefaultVideoChannel,
|
||||||
waitJobs
|
waitJobs
|
||||||
|
@ -49,7 +50,7 @@ export async function downloadZIP (server: PeerTubeServer, userId: number) {
|
||||||
return JSZip.loadAsync(res.body)
|
return JSZip.loadAsync(res.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseZIPJSONFile <T> (zip: JSZip, path: string) {
|
export async function parseZIPJSONFile<T> (zip: JSZip, path: string) {
|
||||||
return JSON.parse(await zip.file(path).async('string')) as T
|
return JSON.parse(await zip.file(path).async('string')) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -218,7 +219,7 @@ export async function prepareImportExportTests (options: {
|
||||||
const noahPrivateVideo = await server.videos.quickUpload({ name: 'noah private video', token: noahToken, privacy: VideoPrivacy.PRIVATE })
|
const noahPrivateVideo = await server.videos.quickUpload({ name: 'noah private video', token: noahToken, privacy: VideoPrivacy.PRIVATE })
|
||||||
const noahVideo = await server.videos.quickUpload({ name: 'noah public video', token: noahToken, privacy: VideoPrivacy.PUBLIC })
|
const noahVideo = await server.videos.quickUpload({ name: 'noah public video', token: noahToken, privacy: VideoPrivacy.PUBLIC })
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
await server.videos.upload({
|
const noahVideo2 = await server.videos.upload({
|
||||||
token: noahToken,
|
token: noahToken,
|
||||||
attributes: {
|
attributes: {
|
||||||
fixture: 'video_short.webm',
|
fixture: 'video_short.webm',
|
||||||
|
@ -248,6 +249,9 @@ export async function prepareImportExportTests (options: {
|
||||||
await server.captions.add({ language: 'ar', videoId: noahVideo.uuid, fixture: 'subtitle-good1.vtt' })
|
await server.captions.add({ language: 'ar', videoId: noahVideo.uuid, fixture: 'subtitle-good1.vtt' })
|
||||||
await server.captions.add({ language: 'fr', videoId: noahVideo.uuid, fixture: 'subtitle-good1.vtt' })
|
await server.captions.add({ language: 'fr', videoId: noahVideo.uuid, fixture: 'subtitle-good1.vtt' })
|
||||||
|
|
||||||
|
await server.captions.add({ language: 'zh', videoId: noahVideo2.uuid, fixture: 'subtitle-good1.vtt' })
|
||||||
|
await server.captions.add({ language: 'es', videoId: noahVideo2.uuid, fixture: 'subtitle-good1.vtt' })
|
||||||
|
|
||||||
// Chapters
|
// Chapters
|
||||||
await server.chapters.update({
|
await server.chapters.update({
|
||||||
videoId: noahVideo.uuid,
|
videoId: noahVideo.uuid,
|
||||||
|
@ -295,7 +299,11 @@ export async function prepareImportExportTests (options: {
|
||||||
await server.playlists.quickCreate({ displayName: 'noah playlist 2', token: noahToken, privacy: VideoPlaylistPrivacy.PRIVATE })
|
await server.playlists.quickCreate({ displayName: 'noah playlist 2', token: noahToken, privacy: VideoPlaylistPrivacy.PRIVATE })
|
||||||
|
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
await server.playlists.addElement({ playlistId: noahPlaylist.uuid, token: noahToken, attributes: { videoId: mouskaVideo.uuid, startTimestamp: 2, stopTimestamp: 3 } })
|
await server.playlists.addElement({
|
||||||
|
playlistId: noahPlaylist.uuid,
|
||||||
|
token: noahToken,
|
||||||
|
attributes: { videoId: mouskaVideo.uuid, startTimestamp: 2, stopTimestamp: 3 }
|
||||||
|
})
|
||||||
await server.playlists.addElement({ playlistId: noahPlaylist.uuid, token: noahToken, attributes: { videoId: noahVideo.uuid } })
|
await server.playlists.addElement({ playlistId: noahPlaylist.uuid, token: noahToken, attributes: { videoId: noahVideo.uuid } })
|
||||||
await server.playlists.addElement({ playlistId: noahPlaylist.uuid, token: noahToken, attributes: { videoId: noahPrivateVideo.uuid } })
|
await server.playlists.addElement({ playlistId: noahPlaylist.uuid, token: noahToken, attributes: { videoId: noahPrivateVideo.uuid } })
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,9 @@ import { getHLS, removeFragmentedMP4Ext, uuidRegex } from '@peertube/peertube-co
|
||||||
import {
|
import {
|
||||||
FileStorage,
|
FileStorage,
|
||||||
HttpStatusCode,
|
HttpStatusCode,
|
||||||
|
VideoCaption,
|
||||||
VideoDetails,
|
VideoDetails,
|
||||||
|
VideoFile,
|
||||||
VideoPrivacy,
|
VideoPrivacy,
|
||||||
VideoResolution,
|
VideoResolution,
|
||||||
VideoStreamingPlaylist,
|
VideoStreamingPlaylist,
|
||||||
|
@ -99,6 +101,7 @@ export async function checkResolutionsInMasterPlaylist (options: {
|
||||||
server: PeerTubeServer
|
server: PeerTubeServer
|
||||||
playlistUrl: string
|
playlistUrl: string
|
||||||
resolutions: number[]
|
resolutions: number[]
|
||||||
|
|
||||||
framerates?: { [id: number]: number }
|
framerates?: { [id: number]: number }
|
||||||
token?: string
|
token?: string
|
||||||
transcoded?: boolean // default true
|
transcoded?: boolean // default true
|
||||||
|
@ -106,6 +109,7 @@ export async function checkResolutionsInMasterPlaylist (options: {
|
||||||
splittedAudio?: boolean // default false
|
splittedAudio?: boolean // default false
|
||||||
hasAudio?: boolean // default true
|
hasAudio?: boolean // default true
|
||||||
hasVideo?: boolean // default true
|
hasVideo?: boolean // default true
|
||||||
|
hasCaptions?: boolean // default false
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
server,
|
server,
|
||||||
|
@ -117,7 +121,8 @@ export async function checkResolutionsInMasterPlaylist (options: {
|
||||||
hasVideo = true,
|
hasVideo = true,
|
||||||
splittedAudio = false,
|
splittedAudio = false,
|
||||||
withRetry = false,
|
withRetry = false,
|
||||||
transcoded = true
|
transcoded = true,
|
||||||
|
hasCaptions = false
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const masterPlaylist = await server.streamingPlaylists.get({ url: playlistUrl, token, withRetry })
|
const masterPlaylist = await server.streamingPlaylists.get({ url: playlistUrl, token, withRetry })
|
||||||
|
@ -156,13 +161,19 @@ export async function checkResolutionsInMasterPlaylist (options: {
|
||||||
regexp += `,(FRAME-RATE=${framerateRegex},)?CODECS="${codecs}"${audioGroup}`
|
regexp += `,(FRAME-RATE=${framerateRegex},)?CODECS="${codecs}"${audioGroup}`
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(masterPlaylist).to.match(new RegExp(`${regexp}`))
|
if (hasCaptions) {
|
||||||
|
regexp += `,SUBTITLES="subtitles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(masterPlaylist, masterPlaylist).to.match(new RegExp(`${regexp}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (splittedAudio && hasAudio && hasVideo) {
|
if (splittedAudio && hasAudio && hasVideo) {
|
||||||
expect(masterPlaylist).to.match(
|
expect(masterPlaylist).to.match(
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
new RegExp(`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="(group_Audio|audio)",NAME="(Audio|audio_0)"(,AUTOSELECT=YES)?,DEFAULT=YES,URI="[^.]*0.m3u8"`)
|
new RegExp(
|
||||||
|
`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="(group_Audio|audio)",NAME="(Audio|audio_0)"(,AUTOSELECT=YES)?,DEFAULT=YES,URI="[^.]*0.m3u8"`
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,6 +185,26 @@ export async function checkResolutionsInMasterPlaylist (options: {
|
||||||
expect(playlistsLength).to.have.lengthOf(playlistsLengthShouldBe)
|
expect(playlistsLength).to.have.lengthOf(playlistsLengthShouldBe)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkCaptionsInMasterPlaylist (options: {
|
||||||
|
server: PeerTubeServer
|
||||||
|
playlistUrl: string
|
||||||
|
captions: VideoCaption[]
|
||||||
|
withRetry?: boolean
|
||||||
|
token?: string
|
||||||
|
}) {
|
||||||
|
const { server, playlistUrl, captions, withRetry, token } = options
|
||||||
|
|
||||||
|
const masterPlaylist = await server.streamingPlaylists.get({ url: playlistUrl, token, withRetry })
|
||||||
|
|
||||||
|
for (const caption of captions) {
|
||||||
|
const toContain =
|
||||||
|
`#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subtitles",NAME="${caption.language.label}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,` +
|
||||||
|
`LANGUAGE="${caption.language.id}",URI="${basename(caption.m3u8Url)}"`
|
||||||
|
|
||||||
|
expect(masterPlaylist, `Master content:\n${masterPlaylist}\n\nTo contain:\n${toContain}\n\n`).to.contain(toContain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function completeCheckHlsPlaylist (options: {
|
export async function completeCheckHlsPlaylist (options: {
|
||||||
servers: PeerTubeServer[]
|
servers: PeerTubeServer[]
|
||||||
videoUUID: string
|
videoUUID: string
|
||||||
|
@ -185,9 +216,11 @@ export async function completeCheckHlsPlaylist (options: {
|
||||||
hasVideo?: boolean // default true
|
hasVideo?: boolean // default true
|
||||||
|
|
||||||
resolutions?: number[]
|
resolutions?: number[]
|
||||||
|
captions?: VideoCaption[]
|
||||||
|
|
||||||
objectStorageBaseUrl?: string
|
objectStorageBaseUrl?: string
|
||||||
}) {
|
}) {
|
||||||
const { videoUUID, hlsOnly, splittedAudio, hasAudio = true, hasVideo = true, objectStorageBaseUrl } = options
|
const { videoUUID, hlsOnly, captions = [], splittedAudio, hasAudio = true, hasVideo = true, objectStorageBaseUrl } = options
|
||||||
|
|
||||||
const hlsResolutions = options.resolutions ?? [ 240, 360, 480, 720 ]
|
const hlsResolutions = options.resolutions ?? [ 240, 360, 480, 720 ]
|
||||||
const webVideoResolutions = [ ...hlsResolutions ]
|
const webVideoResolutions = [ ...hlsResolutions ]
|
||||||
|
@ -225,73 +258,26 @@ export async function completeCheckHlsPlaylist (options: {
|
||||||
// Check JSON files
|
// Check JSON files
|
||||||
for (const resolution of hlsResolutions) {
|
for (const resolution of hlsResolutions) {
|
||||||
const file = hlsFiles.find(f => f.resolution.id === resolution)
|
const file = hlsFiles.find(f => f.resolution.id === resolution)
|
||||||
expect(file).to.not.be.undefined
|
|
||||||
|
|
||||||
if (file.resolution.id === VideoResolution.H_NOVIDEO) {
|
await checkHLSResolution({
|
||||||
expect(file.resolution.label).to.equal('Audio only')
|
file,
|
||||||
expect(file.hasAudio).to.be.true
|
resolution,
|
||||||
expect(file.hasVideo).to.be.false
|
hasAudio,
|
||||||
|
splittedAudio,
|
||||||
expect(file.height).to.equal(0)
|
isOrigin,
|
||||||
expect(file.width).to.equal(0)
|
objectStorageBaseUrl,
|
||||||
} else {
|
requiresAuth,
|
||||||
expect(file.resolution.label).to.equal(resolution + 'p')
|
server,
|
||||||
|
videoDetails,
|
||||||
expect(file.hasVideo).to.be.true
|
privatePath,
|
||||||
expect(file.hasAudio).to.equal(hasAudio && !splittedAudio)
|
baseUrl,
|
||||||
|
token
|
||||||
expect(Math.min(file.height, file.width)).to.equal(resolution)
|
|
||||||
expect(Math.max(file.height, file.width)).to.be.greaterThan(resolution)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOrigin) {
|
|
||||||
if (objectStorageBaseUrl) {
|
|
||||||
expect(file.storage).to.equal(FileStorage.OBJECT_STORAGE)
|
|
||||||
} else {
|
|
||||||
expect(file.storage).to.equal(FileStorage.FILE_SYSTEM)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
expect(file.storage).to.be.null
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(file.magnetUri).to.have.lengthOf.above(2)
|
|
||||||
await checkWebTorrentWorks(file.magnetUri)
|
|
||||||
|
|
||||||
expect(file.playlistUrl).to.equal(file.fileUrl.replace(/-fragmented.mp4$/, '.m3u8'))
|
|
||||||
|
|
||||||
{
|
|
||||||
const nameReg = `${uuidRegex}-${file.resolution.id}`
|
|
||||||
|
|
||||||
expect(file.torrentUrl).to.match(new RegExp(`${server.url}/lazy-static/torrents/${nameReg}-hls.torrent`))
|
|
||||||
|
|
||||||
if (objectStorageBaseUrl && requiresAuth) {
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
expect(file.fileUrl).to.match(new RegExp(`${server.url}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}-fragmented.mp4`))
|
|
||||||
} else if (objectStorageBaseUrl) {
|
|
||||||
expectStartWith(file.fileUrl, objectStorageBaseUrl)
|
|
||||||
} else {
|
|
||||||
expect(file.fileUrl).to.match(
|
|
||||||
new RegExp(`${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}-fragmented.mp4`)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
await Promise.all([
|
|
||||||
makeRawRequest({ url: file.torrentUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
|
|
||||||
makeRawRequest({ url: file.torrentDownloadUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
|
|
||||||
makeRawRequest({ url: file.metadataUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
|
|
||||||
makeRawRequest({ url: file.fileUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
|
|
||||||
|
|
||||||
makeRawRequest({
|
|
||||||
url: file.fileDownloadUrl,
|
|
||||||
token,
|
|
||||||
expectedStatus: objectStorageBaseUrl
|
|
||||||
? HttpStatusCode.FOUND_302
|
|
||||||
: HttpStatusCode.OK_200
|
|
||||||
})
|
})
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check captions
|
||||||
|
for (const caption of captions) {
|
||||||
|
await checkHLSCaption({ caption, objectStorageBaseUrl, videoDetails, privatePath, baseUrl, token })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check master playlist
|
// Check master playlist
|
||||||
|
@ -303,14 +289,21 @@ export async function completeCheckHlsPlaylist (options: {
|
||||||
resolutions: hlsResolutions,
|
resolutions: hlsResolutions,
|
||||||
hasAudio,
|
hasAudio,
|
||||||
hasVideo,
|
hasVideo,
|
||||||
splittedAudio
|
splittedAudio,
|
||||||
|
hasCaptions: captions.length !== 0
|
||||||
|
})
|
||||||
|
|
||||||
|
await checkCaptionsInMasterPlaylist({
|
||||||
|
server,
|
||||||
|
token,
|
||||||
|
playlistUrl: hlsPlaylist.playlistUrl,
|
||||||
|
captions
|
||||||
})
|
})
|
||||||
|
|
||||||
const masterPlaylist = await server.streamingPlaylists.get({ url: hlsPlaylist.playlistUrl, token })
|
const masterPlaylist = await server.streamingPlaylists.get({ url: hlsPlaylist.playlistUrl, token })
|
||||||
|
|
||||||
for (const resolution of hlsResolutions) {
|
for (const resolution of hlsResolutions) {
|
||||||
expect(masterPlaylist).to.contain(`${resolution}.m3u8`)
|
expect(masterPlaylist).to.contain(`${resolution}.m3u8`)
|
||||||
expect(masterPlaylist).to.contain(`${resolution}.m3u8`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -411,3 +404,136 @@ export function extractResolutionPlaylistUrls (masterPath: string, masterContent
|
||||||
return masterContent.match(/[a-z0-9-]+\.m3u8(?:[?a-zA-Z0-9=&-]+)?/mg)
|
return masterContent.match(/[a-z0-9-]+\.m3u8(?:[?a-zA-Z0-9=&-]+)?/mg)
|
||||||
.map(filename => join(dirname(masterPath), filename))
|
.map(filename => join(dirname(masterPath), filename))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function checkHLSResolution (options: {
|
||||||
|
file: VideoFile & { playlistUrl: string }
|
||||||
|
resolution: number
|
||||||
|
hasAudio: boolean
|
||||||
|
splittedAudio: boolean
|
||||||
|
isOrigin: boolean
|
||||||
|
objectStorageBaseUrl: string | undefined
|
||||||
|
requiresAuth: boolean
|
||||||
|
server: PeerTubeServer
|
||||||
|
videoDetails: VideoDetails
|
||||||
|
privatePath: string
|
||||||
|
baseUrl: string
|
||||||
|
token: string | undefined
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
file,
|
||||||
|
resolution,
|
||||||
|
hasAudio,
|
||||||
|
splittedAudio,
|
||||||
|
isOrigin,
|
||||||
|
objectStorageBaseUrl,
|
||||||
|
requiresAuth,
|
||||||
|
server,
|
||||||
|
videoDetails,
|
||||||
|
privatePath,
|
||||||
|
baseUrl,
|
||||||
|
token
|
||||||
|
} = options
|
||||||
|
|
||||||
|
expect(file).to.not.be.undefined
|
||||||
|
|
||||||
|
if (file.resolution.id === VideoResolution.H_NOVIDEO) {
|
||||||
|
expect(file.resolution.label).to.equal('Audio only')
|
||||||
|
expect(file.hasAudio).to.be.true
|
||||||
|
expect(file.hasVideo).to.be.false
|
||||||
|
|
||||||
|
expect(file.height).to.equal(0)
|
||||||
|
expect(file.width).to.equal(0)
|
||||||
|
} else {
|
||||||
|
expect(file.resolution.label).to.equal(`${resolution}p`)
|
||||||
|
|
||||||
|
expect(file.hasVideo).to.be.true
|
||||||
|
expect(file.hasAudio).to.equal(hasAudio && !splittedAudio)
|
||||||
|
|
||||||
|
expect(Math.min(file.height, file.width)).to.equal(resolution)
|
||||||
|
expect(Math.max(file.height, file.width)).to.be.greaterThan(resolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOrigin) {
|
||||||
|
if (objectStorageBaseUrl) {
|
||||||
|
expect(file.storage).to.equal(FileStorage.OBJECT_STORAGE)
|
||||||
|
} else {
|
||||||
|
expect(file.storage).to.equal(FileStorage.FILE_SYSTEM)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expect(file.storage).to.be.null
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(file.magnetUri).to.have.lengthOf.above(2)
|
||||||
|
await checkWebTorrentWorks(file.magnetUri)
|
||||||
|
|
||||||
|
expect(file.playlistUrl).to.equal(file.fileUrl.replace(/-fragmented.mp4$/, '.m3u8'))
|
||||||
|
|
||||||
|
const nameReg = `${uuidRegex}-${file.resolution.id}`
|
||||||
|
|
||||||
|
expect(file.torrentUrl).to.match(new RegExp(`${server.url}/lazy-static/torrents/${nameReg}-hls.torrent`))
|
||||||
|
|
||||||
|
if (objectStorageBaseUrl && requiresAuth) {
|
||||||
|
expect(file.fileUrl).to.match(
|
||||||
|
new RegExp(
|
||||||
|
`${server.url}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}-fragmented.mp4`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (objectStorageBaseUrl) {
|
||||||
|
expectStartWith(file.fileUrl, objectStorageBaseUrl)
|
||||||
|
} else {
|
||||||
|
expect(file.fileUrl).to.match(
|
||||||
|
new RegExp(`${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}-fragmented.mp4`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
makeRawRequest({ url: file.torrentUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
|
||||||
|
makeRawRequest({ url: file.torrentDownloadUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
|
||||||
|
makeRawRequest({ url: file.metadataUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
|
||||||
|
makeRawRequest({ url: file.fileUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
|
||||||
|
|
||||||
|
makeRawRequest({
|
||||||
|
url: file.fileDownloadUrl,
|
||||||
|
token,
|
||||||
|
expectedStatus: objectStorageBaseUrl
|
||||||
|
? HttpStatusCode.FOUND_302
|
||||||
|
: HttpStatusCode.OK_200
|
||||||
|
})
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkHLSCaption (options: {
|
||||||
|
caption: VideoCaption
|
||||||
|
objectStorageBaseUrl: string | undefined
|
||||||
|
videoDetails: VideoDetails
|
||||||
|
privatePath: string
|
||||||
|
baseUrl: string
|
||||||
|
token: string | undefined
|
||||||
|
}) {
|
||||||
|
const { caption, objectStorageBaseUrl, videoDetails, privatePath, baseUrl, token } = options
|
||||||
|
|
||||||
|
expect(caption.fileUrl).to.exist
|
||||||
|
expect(caption.m3u8Url).to.exist
|
||||||
|
expect(basename(caption.m3u8Url)).to.equal(basename(caption.fileUrl).replace(/\.vtt$/, '.m3u8'))
|
||||||
|
|
||||||
|
if (objectStorageBaseUrl) {
|
||||||
|
expectStartWith(caption.m3u8Url, objectStorageBaseUrl)
|
||||||
|
} else {
|
||||||
|
const nameReg = basename(caption.fileUrl).replace(/\.vtt$/, '.m3u8')
|
||||||
|
|
||||||
|
expect(caption.m3u8Url).to.match(
|
||||||
|
new RegExp(`${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await makeRawRequest({ url: caption.fileUrl, token, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
const { text } = await makeRawRequest({ url: caption.m3u8Url, token, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
expect(text).to.match(new RegExp(`^#EXTM3U`))
|
||||||
|
expect(text).to.include(`#EXT-X-TARGETDURATION:${videoDetails.duration}`)
|
||||||
|
expect(text).to.include(caption.fileUrl)
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { HttpStatusCode, VideoCaptionGenerate } from '@peertube/peertube-models'
|
import { HttpStatusCode, VideoCaptionGenerate } from '@peertube/peertube-models'
|
||||||
|
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks.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 { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { createReqFiles } from '../../../helpers/express-utils.js'
|
import { createReqFiles } from '../../../helpers/express-utils.js'
|
||||||
|
@ -17,7 +18,6 @@ import {
|
||||||
listVideoCaptionsValidator
|
listVideoCaptionsValidator
|
||||||
} from '../../../middlewares/validators/index.js'
|
} from '../../../middlewares/validators/index.js'
|
||||||
import { VideoCaptionModel } from '../../../models/video/video-caption.js'
|
import { VideoCaptionModel } from '../../../models/video/video-caption.js'
|
||||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('api', 'video-caption')
|
const lTags = loggerTagsFactory('api', 'video-caption')
|
||||||
|
|
||||||
|
@ -25,25 +25,25 @@ const reqVideoCaptionAdd = createReqFiles([ 'captionfile' ], MIMETYPES.VIDEO_CAP
|
||||||
|
|
||||||
const videoCaptionsRouter = express.Router()
|
const videoCaptionsRouter = express.Router()
|
||||||
|
|
||||||
videoCaptionsRouter.post('/:videoId/captions/generate',
|
videoCaptionsRouter.post(
|
||||||
|
'/:videoId/captions/generate',
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncMiddleware(generateVideoCaptionValidator),
|
asyncMiddleware(generateVideoCaptionValidator),
|
||||||
asyncMiddleware(createGenerateVideoCaption)
|
asyncMiddleware(createGenerateVideoCaption)
|
||||||
)
|
)
|
||||||
|
|
||||||
videoCaptionsRouter.get('/:videoId/captions',
|
videoCaptionsRouter.get('/:videoId/captions', asyncMiddleware(listVideoCaptionsValidator), asyncMiddleware(listVideoCaptions))
|
||||||
asyncMiddleware(listVideoCaptionsValidator),
|
|
||||||
asyncMiddleware(listVideoCaptions)
|
|
||||||
)
|
|
||||||
|
|
||||||
videoCaptionsRouter.put('/:videoId/captions/:captionLanguage',
|
videoCaptionsRouter.put(
|
||||||
|
'/:videoId/captions/:captionLanguage',
|
||||||
authenticate,
|
authenticate,
|
||||||
reqVideoCaptionAdd,
|
reqVideoCaptionAdd,
|
||||||
asyncMiddleware(addVideoCaptionValidator),
|
asyncMiddleware(addVideoCaptionValidator),
|
||||||
asyncMiddleware(createVideoCaption)
|
asyncMiddleware(createVideoCaption)
|
||||||
)
|
)
|
||||||
|
|
||||||
videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage',
|
videoCaptionsRouter.delete(
|
||||||
|
'/:videoId/captions/:captionLanguage',
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncMiddleware(deleteVideoCaptionValidator),
|
asyncMiddleware(deleteVideoCaptionValidator),
|
||||||
asyncRetryTransactionMiddleware(deleteVideoCaption)
|
asyncRetryTransactionMiddleware(deleteVideoCaption)
|
||||||
|
@ -89,10 +89,12 @@ async function createVideoCaption (req: express.Request, res: express.Response)
|
||||||
automaticallyGenerated: false
|
automaticallyGenerated: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (videoCaption.m3u8Filename) {
|
||||||
|
await updateHLSMasterOnCaptionChangeIfNeeded(video)
|
||||||
|
}
|
||||||
|
|
||||||
await retryTransactionWrapper(() => {
|
await retryTransactionWrapper(() => {
|
||||||
return sequelizeTypescript.transaction(async t => {
|
return sequelizeTypescript.transaction(t => federateVideoIfNeeded(video, false, t))
|
||||||
return federateVideoIfNeeded(video, false, t)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Hooks.runAction('action:api.video-caption.created', { caption: videoCaption, req, res })
|
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) {
|
async function deleteVideoCaption (req: express.Request, res: express.Response) {
|
||||||
const video = res.locals.videoAll
|
const video = res.locals.videoAll
|
||||||
const videoCaption = res.locals.videoCaption
|
const videoCaption = res.locals.videoCaption
|
||||||
|
const hasM3U8 = !!videoCaption.m3u8Filename
|
||||||
|
|
||||||
await sequelizeTypescript.transaction(async t => {
|
await sequelizeTypescript.transaction(async t => {
|
||||||
await videoCaption.destroy({ transaction: t })
|
await videoCaption.destroy({ transaction: t })
|
||||||
|
})
|
||||||
|
|
||||||
// Send video update
|
if (hasM3U8) {
|
||||||
await federateVideoIfNeeded(video, false, t)
|
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))
|
logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid, lTags(video.uuid))
|
||||||
|
|
|
@ -29,14 +29,16 @@ const lTags = loggerTagsFactory('api', 'video')
|
||||||
|
|
||||||
const videoSourceRouter = express.Router()
|
const videoSourceRouter = express.Router()
|
||||||
|
|
||||||
videoSourceRouter.get('/:id/source',
|
videoSourceRouter.get(
|
||||||
|
'/:id/source',
|
||||||
openapiOperationDoc({ operationId: 'getVideoSource' }),
|
openapiOperationDoc({ operationId: 'getVideoSource' }),
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncMiddleware(videoSourceGetLatestValidator),
|
asyncMiddleware(videoSourceGetLatestValidator),
|
||||||
getVideoLatestSource
|
getVideoLatestSource
|
||||||
)
|
)
|
||||||
|
|
||||||
videoSourceRouter.delete('/:id/source/file',
|
videoSourceRouter.delete(
|
||||||
|
'/:id/source/file',
|
||||||
openapiOperationDoc({ operationId: 'deleteVideoSourceFile' }),
|
openapiOperationDoc({ operationId: 'deleteVideoSourceFile' }),
|
||||||
authenticate,
|
authenticate,
|
||||||
ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
|
ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
|
||||||
|
@ -211,6 +213,6 @@ async function removeOldFiles (options: {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const playlist of playlists) {
|
for (const playlist of playlists) {
|
||||||
await video.removeStreamingPlaylistFiles(playlist)
|
await video.removeAllStreamingPlaylistFiles({ playlist })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -104,5 +104,5 @@ async function servePrivateM3U8 (req: express.Request, res: express.Response) {
|
||||||
? injectQueryToPlaylistUrls(playlistContent, buildReinjectVideoFileTokenQuery(req, filename.endsWith('master.m3u8')))
|
? injectQueryToPlaylistUrls(playlistContent, buildReinjectVideoFileTokenQuery(req, filename.endsWith('master.m3u8')))
|
||||||
: playlistContent
|
: 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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { Transform } from 'stream'
|
||||||
import { MVideoCaption } from '@server/types/models/index.js'
|
import { MVideoCaption } from '@server/types/models/index.js'
|
||||||
import { pipelinePromise } from './core-utils.js'
|
import { pipelinePromise } from './core-utils.js'
|
||||||
|
|
||||||
async function moveAndProcessCaptionFile (physicalFile: { filename?: string, path: string }, videoCaption: MVideoCaption) {
|
export async function moveAndProcessCaptionFile (physicalFile: { filename?: string, path: string }, videoCaption: MVideoCaption) {
|
||||||
const destination = videoCaption.getFSPath()
|
const destination = videoCaption.getFSFilePath()
|
||||||
|
|
||||||
// Convert this srt file to vtt
|
// Convert this srt file to vtt
|
||||||
if (physicalFile.path.endsWith('.srt')) {
|
if (physicalFile.path.endsWith('.srt')) {
|
||||||
|
@ -22,12 +22,6 @@ async function moveAndProcessCaptionFile (physicalFile: { filename?: string, pat
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
|
||||||
moveAndProcessCaptionFile
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function convertSrtToVtt (source: string, destination: string) {
|
async function convertSrtToVtt (source: string, destination: string) {
|
||||||
const fixVTT = new Transform({
|
const fixVTT = new Transform({
|
||||||
transform: (chunk, _encoding, cb) => {
|
transform: (chunk, _encoding, cb) => {
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { arrayify } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import {
|
||||||
|
ActivityCaptionUrlObject,
|
||||||
ActivityPubStoryboard,
|
ActivityPubStoryboard,
|
||||||
ActivityTrackerUrlObject,
|
ActivityTrackerUrlObject,
|
||||||
ActivityVideoFileMetadataUrlObject,
|
ActivityVideoFileMetadataUrlObject,
|
||||||
|
@ -139,6 +141,13 @@ export function isAPVideoTrackerUrlObject (url: any): url is ActivityTrackerUrlO
|
||||||
isActivityPubUrlValid(url.href)
|
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
|
// Private
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
@ -157,7 +166,21 @@ function setValidRemoteCaptions (video: VideoObject) {
|
||||||
if (Array.isArray(video.subtitleLanguage) === false) return false
|
if (Array.isArray(video.subtitleLanguage) === false) return false
|
||||||
|
|
||||||
video.subtitleLanguage = video.subtitleLanguage.filter(caption => {
|
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)
|
return isRemoteStringIdentifierValid(caption)
|
||||||
})
|
})
|
||||||
|
|
|
@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const LAST_MIGRATION_VERSION = 885
|
export const LAST_MIGRATION_VERSION = 890
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
34
server/core/initializers/migrations/0890-hls-caption.ts
Normal file
34
server/core/initializers/migrations/0890-hls-caption.ts
Normal 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
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import { arrayify, maxBy, minBy } from '@peertube/peertube-core-utils'
|
import { arrayify, maxBy, minBy } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import {
|
||||||
|
ActivityCaptionUrlObject,
|
||||||
ActivityHashTagObject,
|
ActivityHashTagObject,
|
||||||
ActivityMagnetUrlObject,
|
ActivityMagnetUrlObject,
|
||||||
ActivityPlaylistSegmentHashesObject,
|
ActivityPlaylistSegmentHashesObject,
|
||||||
|
@ -204,13 +205,23 @@ export function getLiveAttributesFromObject (video: MVideoId, videoObject: Video
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
export function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||||
return videoObject.subtitleLanguage.map(c => ({
|
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,
|
videoId: video.id,
|
||||||
filename: VideoCaptionModel.generateCaptionName(c.identifier),
|
filename,
|
||||||
language: c.identifier,
|
language: c.identifier,
|
||||||
automaticallyGenerated: c.automaticallyGenerated === true,
|
automaticallyGenerated: c.automaticallyGenerated === true,
|
||||||
fileUrl: c.url
|
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) {
|
export function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||||
|
|
|
@ -6,8 +6,7 @@ import { VideoModel } from '../../models/video/video.js'
|
||||||
import { VideoCaptionModel } from '../../models/video/video-caption.js'
|
import { VideoCaptionModel } from '../../models/video/video-caption.js'
|
||||||
import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.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 static instance: VideoCaptionsSimpleFileCache
|
||||||
|
|
||||||
private constructor () {
|
private constructor () {
|
||||||
|
@ -23,7 +22,7 @@ class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache <string> {
|
||||||
if (!videoCaption) return undefined
|
if (!videoCaption) return undefined
|
||||||
|
|
||||||
if (videoCaption.isOwned()) {
|
if (videoCaption.isOwned()) {
|
||||||
return { isOwned: true, path: videoCaption.getFSPath() }
|
return { isOwned: true, path: videoCaption.getFSFilePath() }
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.loadRemoteFile(filename)
|
return this.loadRemoteFile(filename)
|
||||||
|
|
|
@ -2,7 +2,8 @@ import { sortBy, uniqify, uuidRegex } from '@peertube/peertube-core-utils'
|
||||||
import { ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
|
import { ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
|
||||||
import { FileStorage, VideoResolution } from '@peertube/peertube-models'
|
import { FileStorage, VideoResolution } from '@peertube/peertube-models'
|
||||||
import { sha256 } from '@peertube/peertube-node-utils'
|
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 { ensureDir, move, outputJSON, remove } from 'fs-extra/esm'
|
||||||
import { open, readFile, stat, writeFile } from 'fs/promises'
|
import { open, readFile, stat, writeFile } from 'fs/promises'
|
||||||
import flatten from 'lodash-es/flatten.js'
|
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 { VideoFileModel } from '../models/video/video-file.js'
|
||||||
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist.js'
|
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist.js'
|
||||||
import { storeHLSFileFromContent } from './object-storage/index.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'
|
import { VideoPathManager } from './video-path-manager.js'
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('hls')
|
const lTags = loggerTagsFactory('hls')
|
||||||
|
@ -65,19 +66,31 @@ export async function updateM3U8AndShaPlaylist (video: MVideo, playlist: MStream
|
||||||
// Avoid concurrency issues when updating streaming playlist files
|
// Avoid concurrency issues when updating streaming playlist files
|
||||||
const playlistFilesQueue = new PQueue({ concurrency: 1 })
|
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 () => {
|
return playlistFilesQueue.add(async () => {
|
||||||
const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
|
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[] = []
|
const extStreamInfo: string[] = []
|
||||||
let separatedAudioCodec: string
|
let separatedAudioCodec: string
|
||||||
|
|
||||||
const splitAudioAndVideo = playlist.hasAudioAndVideoSplitted()
|
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)
|
// Sort to have the audio resolution first (if it exists)
|
||||||
for (const file of sortBy(playlist.VideoFiles, 'resolution')) {
|
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 => {
|
await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
|
||||||
const probe = await ffprobePromise(videoFilePath)
|
const probe = await ffprobePromise(videoFilePath)
|
||||||
|
@ -103,9 +116,8 @@ export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingP
|
||||||
|
|
||||||
line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
|
line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
|
||||||
|
|
||||||
if (splitAudioAndVideo) {
|
if (splitAudioAndVideo) line += `,AUDIO="audio"`
|
||||||
line += `,AUDIO="audio"`
|
if (extMediaSubtitle.length !== 0) line += `,SUBTITLES="subtitles"`
|
||||||
}
|
|
||||||
|
|
||||||
// Don't include audio only resolution as a regular "video" resolution
|
// 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
|
// 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(line)
|
||||||
extStreamInfo.push(playlistFilename)
|
extStreamInfo.push(playlistFilename)
|
||||||
} else if (splitAudioAndVideo) {
|
} 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) {
|
if (playlist.playlistFilename) {
|
||||||
await video.removeStreamingPlaylistFile(playlist, 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'
|
const masterPlaylistContent = masterPlaylists.join('\n') + '\n'
|
||||||
|
|
||||||
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
|
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))
|
logger.info(`Updated master playlist file of video ${video.uuid} to object storage ${playlist.playlistUrl}`, lTags(video.uuid))
|
||||||
} else {
|
} 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 () => {
|
return playlistFilesQueue.add(async () => {
|
||||||
const json: { [filename: string]: { [range: string]: string } } = {}
|
const json: { [filename: string]: { [range: string]: string } } = {}
|
||||||
|
|
||||||
|
@ -157,7 +174,6 @@ export function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingP
|
||||||
const fileWithPlaylist = file.withVideoOrPlaylist(playlist)
|
const fileWithPlaylist = file.withVideoOrPlaylist(playlist)
|
||||||
|
|
||||||
await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => {
|
await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => {
|
||||||
|
|
||||||
return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => {
|
return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => {
|
||||||
const playlistContent = await readFile(resolutionPlaylistPath)
|
const playlistContent = await readFile(resolutionPlaylistPath)
|
||||||
const ranges = getRangesFromPlaylist(playlistContent.toString())
|
const ranges = getRangesFromPlaylist(playlistContent.toString())
|
||||||
|
@ -183,7 +199,12 @@ export function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingP
|
||||||
playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive)
|
playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive)
|
||||||
|
|
||||||
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
|
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 {
|
} else {
|
||||||
const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename)
|
const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename)
|
||||||
await outputJSON(outputPath, json)
|
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 })
|
await doRequestAndSaveToFile(fileUrl, destPath, { bodyKBLimit: remainingBodyKBLimit, timeout: REQUEST_TIMEOUTS.REDUNDANCY })
|
||||||
|
|
||||||
const { size } = await stat(destPath)
|
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))
|
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)
|
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
|
// Private
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -13,7 +13,8 @@ import {
|
||||||
removeOriginalFileObjectStorage,
|
removeOriginalFileObjectStorage,
|
||||||
removeWebVideoObjectStorage
|
removeWebVideoObjectStorage
|
||||||
} from '@server/lib/object-storage/index.js'
|
} 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 { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||||
import { moveToFailedMoveToFileSystemState, moveToNextState } from '@server/lib/video-state.js'
|
import { moveToFailedMoveToFileSystemState, moveToNextState } from '@server/lib/video-state.js'
|
||||||
import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.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
|
if (file.storage === FileStorage.FILE_SYSTEM) continue
|
||||||
|
|
||||||
// Resolution playlist
|
// Resolution playlist
|
||||||
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
|
const playlistFilename = getHLSResolutionPlaylistFilename(file.filename)
|
||||||
await makeHLSFileAvailable(playlistWithVideo, playlistFilename, join(getHLSDirectory(video), playlistFilename))
|
await makeHLSFileAvailable(playlistWithVideo, playlistFilename, join(getHLSDirectory(video), playlistFilename))
|
||||||
await makeHLSFileAvailable(playlistWithVideo, file.filename, join(getHLSDirectory(video), file.filename))
|
await makeHLSFileAvailable(playlistWithVideo, file.filename, join(getHLSDirectory(video), file.filename))
|
||||||
|
|
||||||
|
@ -152,22 +153,48 @@ async function onVideoFileMoved (options: {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function moveCaptionFiles (captions: MVideoCaption[]) {
|
async function moveCaptionFiles (captions: MVideoCaption[], hls: MStreamingPlaylistVideo) {
|
||||||
|
let hlsUpdated = false
|
||||||
|
|
||||||
for (const caption of captions) {
|
for (const caption of captions) {
|
||||||
if (caption.storage === FileStorage.FILE_SYSTEM) continue
|
if (caption.storage === FileStorage.OBJECT_STORAGE) {
|
||||||
|
|
||||||
await makeCaptionFileAvailable(caption.filename, caption.getFSPath())
|
|
||||||
|
|
||||||
const oldFileUrl = caption.fileUrl
|
const oldFileUrl = caption.fileUrl
|
||||||
|
|
||||||
|
await makeCaptionFileAvailable(caption.filename, caption.getFSFilePath())
|
||||||
|
|
||||||
|
// Assign new values before building the m3u8 file
|
||||||
caption.fileUrl = null
|
caption.fileUrl = null
|
||||||
caption.storage = FileStorage.FILE_SYSTEM
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -2,8 +2,16 @@ import { FileStorage, isMoveCaptionPayload, isMoveVideoStoragePayload, MoveStora
|
||||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||||
import { updateTorrentMetadata } from '@server/helpers/webtorrent.js'
|
import { updateTorrentMetadata } from '@server/helpers/webtorrent.js'
|
||||||
import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants.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 { buildCaptionM3U8Content } from '@server/lib/hls.js'
|
||||||
import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths.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 { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||||
import { moveToFailedMoveToObjectStorageState, moveToNextState } from '@server/lib/video-state.js'
|
import { moveToFailedMoveToObjectStorageState, moveToNextState } from '@server/lib/video-state.js'
|
||||||
import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.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 { join } from 'path'
|
||||||
import { moveCaptionToStorageJob } from './shared/move-caption.js'
|
import { moveCaptionToStorageJob } from './shared/move-caption.js'
|
||||||
import { moveVideoToStorageJob, onMoveVideoToStorageFailure } from './shared/move-video.js'
|
import { moveVideoToStorageJob, onMoveVideoToStorageFailure } from './shared/move-video.js'
|
||||||
|
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||||
|
|
||||||
const lTagsBase = loggerTagsFactory('move-object-storage')
|
const lTagsBase = loggerTagsFactory('move-object-storage')
|
||||||
|
|
||||||
|
@ -84,21 +93,51 @@ 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) {
|
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
|
if (file.storage !== FileStorage.FILE_SYSTEM) continue
|
||||||
|
|
||||||
// Resolution playlist
|
// Resolution playlist
|
||||||
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
|
const playlistFilename = getHLSResolutionPlaylistFilename(file.filename)
|
||||||
await storeHLSFileFromFilename(playlistWithVideo, playlistFilename)
|
await storeHLSFileFromFilename(playlistWithVideo, playlistFilename)
|
||||||
|
|
||||||
// Resolution fragmented file
|
// Resolution fragmented file
|
||||||
|
|
|
@ -4,15 +4,16 @@ import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||||
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
|
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||||
import { VideoCaptionModel } from '@server/models/video/video-caption.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 { 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: {
|
export async function moveCaptionToStorageJob (options: {
|
||||||
jobId: string
|
jobId: string
|
||||||
captionId: number
|
captionId: number
|
||||||
loggerTags: (number | string)[]
|
loggerTags: (number | string)[]
|
||||||
|
|
||||||
moveCaptionFiles: (captions: MVideoCaption[]) => Promise<void>
|
moveCaptionFiles: (captions: MVideoCaption[], hls: MStreamingPlaylistVideoUUID) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
jobId,
|
jobId,
|
||||||
|
@ -32,8 +33,10 @@ export async function moveCaptionToStorageJob (options: {
|
||||||
|
|
||||||
const fileMutexReleaser = await VideoPathManager.Instance.lockFiles(caption.Video.uuid)
|
const fileMutexReleaser = await VideoPathManager.Instance.lockFiles(caption.Video.uuid)
|
||||||
|
|
||||||
|
const hls = await VideoStreamingPlaylistModel.loadHLSByVideoWithVideo(caption.videoId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await moveCaptionFiles([ caption ])
|
await moveCaptionFiles([ caption ], hls)
|
||||||
|
|
||||||
await retryTransactionWrapper(() => {
|
await retryTransactionWrapper(() => {
|
||||||
return sequelizeTypescript.transaction(async t => {
|
return sequelizeTypescript.transaction(async t => {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||||
import { VideoSourceModel } from '@server/models/video/video-source.js'
|
import { VideoSourceModel } from '@server/models/video/video-source.js'
|
||||||
import { VideoModel } from '@server/models/video/video.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'
|
import { MVideoSource } from '@server/types/models/video/video-source.js'
|
||||||
|
|
||||||
export async function moveVideoToStorageJob (options: {
|
export async function moveVideoToStorageJob (options: {
|
||||||
|
@ -15,7 +15,7 @@ export async function moveVideoToStorageJob (options: {
|
||||||
moveWebVideoFiles: (video: MVideoWithAllFiles) => Promise<void>
|
moveWebVideoFiles: (video: MVideoWithAllFiles) => Promise<void>
|
||||||
moveHLSFiles: (video: MVideoWithAllFiles) => Promise<void>
|
moveHLSFiles: (video: MVideoWithAllFiles) => Promise<void>
|
||||||
moveVideoSourceFile: (source: MVideoSource) => Promise<void>
|
moveVideoSourceFile: (source: MVideoSource) => Promise<void>
|
||||||
moveCaptionFiles: (captions: MVideoCaption[]) => Promise<void>
|
moveCaptionFiles: (captions: MVideoCaption[], hls: MStreamingPlaylistVideoUUID) => Promise<void>
|
||||||
|
|
||||||
moveToFailedState: (video: MVideoWithAllFiles) => Promise<void>
|
moveToFailedState: (video: MVideoWithAllFiles) => Promise<void>
|
||||||
doAfterLastMove: (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)
|
const captions = await VideoCaptionModel.listVideoCaptions(video.id)
|
||||||
if (captions.length !== 0) {
|
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')
|
const pendingMove = await VideoJobInfoModel.decrease(video.uuid, 'pendingMove')
|
||||||
|
|
|
@ -56,7 +56,6 @@ const config = {
|
||||||
const lTags = loggerTagsFactory('live')
|
const lTags = loggerTagsFactory('live')
|
||||||
|
|
||||||
class LiveManager {
|
class LiveManager {
|
||||||
|
|
||||||
private static instance: LiveManager
|
private static instance: LiveManager
|
||||||
|
|
||||||
private readonly muxingSessions = new Map<string, MuxingSession>()
|
private readonly muxingSessions = new Map<string, MuxingSession>()
|
||||||
|
@ -274,13 +273,16 @@ class LiveManager {
|
||||||
if (this.videoSessions.has(video.uuid)) {
|
if (this.videoSessions.has(video.uuid)) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Video %s has already a live session %s. Refusing stream %s.',
|
'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)
|
return this.abortSession(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup old potential live (could happen with a permanent live)
|
// 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 (oldStreamingPlaylist) {
|
||||||
if (!videoLive.permanentLive) throw new Error('Found previous session in a non permanent live: ' + video.uuid)
|
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) {
|
if (!hasAudio && !hasVideo) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Not audio and video streams were found for video %s. Refusing stream %s.',
|
'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)
|
this.videoSessions.delete(video.uuid)
|
||||||
|
@ -325,7 +329,12 @@ class LiveManager {
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'%s probing took %d ms (bitrate: %d, fps: %d, resolution: %d)',
|
'%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(
|
const allResolutions = await Hooks.wrapObject(
|
||||||
|
@ -337,7 +346,9 @@ class LiveManager {
|
||||||
if (!hasAudio && allResolutions.length === 1 && allResolutions[0] === VideoResolution.H_NOVIDEO) {
|
if (!hasAudio && allResolutions.length === 1 && allResolutions[0] === VideoResolution.H_NOVIDEO) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Cannot stream live to audio only because no video stream is available for video %s. Refusing stream %s.',
|
'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)
|
this.videoSessions.delete(video.uuid)
|
||||||
|
@ -345,7 +356,8 @@ class LiveManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'Handling live video of original resolution %d.', resolution,
|
'Handling live video of original resolution %d.',
|
||||||
|
resolution,
|
||||||
{ allResolutions, ...lTags(sessionId, video.uuid) }
|
{ allResolutions, ...lTags(sessionId, video.uuid) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -426,7 +438,8 @@ class LiveManager {
|
||||||
muxingSession.on('bad-socket-health', ({ videoUUID }) => {
|
muxingSession.on('bad-socket-health', ({ videoUUID }) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Too much data in client socket stream (ffmpeg is too slow to transcode the video).' +
|
'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
|
localLTags
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -51,16 +51,17 @@ interface MuxingSessionEvents {
|
||||||
|
|
||||||
declare interface MuxingSession {
|
declare interface MuxingSession {
|
||||||
on<U extends keyof MuxingSessionEvents>(
|
on<U extends keyof MuxingSessionEvents>(
|
||||||
event: U, listener: MuxingSessionEvents[U]
|
event: U,
|
||||||
|
listener: MuxingSessionEvents[U]
|
||||||
): this
|
): this
|
||||||
|
|
||||||
emit<U extends keyof MuxingSessionEvents>(
|
emit<U extends keyof MuxingSessionEvents>(
|
||||||
event: U, ...args: Parameters<MuxingSessionEvents[U]>
|
event: U,
|
||||||
|
...args: Parameters<MuxingSessionEvents[U]>
|
||||||
): boolean
|
): boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
class MuxingSession extends EventEmitter {
|
class MuxingSession extends EventEmitter {
|
||||||
|
|
||||||
private transcodingWrapper: AbstractTranscodingWrapper
|
private transcodingWrapper: AbstractTranscodingWrapper
|
||||||
|
|
||||||
private readonly context: any
|
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() })
|
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
|
this.streamingPlaylist.playlistUrl = url
|
||||||
}
|
}
|
||||||
|
@ -405,18 +413,25 @@ class MuxingSession extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = this.objectStorageSendQueues.get(m3u8Path)
|
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) {
|
} catch (err) {
|
||||||
logger.error('Cannot store in object storage m3u8 file %s', m3u8Path, { err, ...this.lTags() })
|
logger.error('Cannot store in object storage m3u8 file %s', m3u8Path, { err, ...this.lTags() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onTranscodingError () {
|
private onTranscodingError () {
|
||||||
this.emit('transcoding-error', ({ videoUUID: this.videoUUID }))
|
this.emit('transcoding-error', { videoUUID: this.videoUUID })
|
||||||
}
|
}
|
||||||
|
|
||||||
private onTranscodedEnded () {
|
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())
|
logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', this.inputLocalUrl, this.lTags())
|
||||||
|
|
||||||
|
@ -433,7 +448,8 @@ class MuxingSession extends EventEmitter {
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
logger.error(
|
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() }
|
{ err, ...this.lTags() }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -482,7 +498,7 @@ class MuxingSession extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createLivePlaylist (): Promise<MStreamingPlaylistVideo> {
|
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.playlistFilename = generateHLSMasterPlaylistFilename(true)
|
||||||
playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(true)
|
playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(true)
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
|
import { MStreamingPlaylistVideoUUID } from '@server/types/models/index.js'
|
||||||
import { join } from 'path'
|
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)
|
return join(generateHLSObjectBaseStorageKey(playlist), filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideo) {
|
export function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideoUUID) {
|
||||||
return join(playlist.getStringType(), playlist.Video.uuid)
|
return join(playlist.getStringType(), playlist.Video.uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { logger } from '@server/helpers/logger.js'
|
import { logger } from '@server/helpers/logger.js'
|
||||||
import { CONFIG } from '@server/initializers/config.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 { MVideoSource } from '@server/types/models/video/video-source.js'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import { getHLSDirectory } from '../paths.js'
|
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({
|
return storeContent({
|
||||||
content,
|
content,
|
||||||
objectStorageKey: generateHLSObjectStorageKey(playlist, basename(pathOrFilename)),
|
objectStorageKey: generateHLSObjectStorageKey(playlist, basename(pathOrFilename)),
|
||||||
bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS,
|
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)
|
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)
|
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)
|
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)
|
const key = generateHLSObjectStorageKey(playlist, filename)
|
||||||
|
|
||||||
logger.info('Fetching HLS file %s from object storage to %s.', key, destination, lTags())
|
logger.info('Fetching HLS file %s from object storage to %s.', key, destination, lTags())
|
||||||
|
|
|
@ -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 { CONFIG } from '@server/initializers/config.js'
|
||||||
import { DIRECTORIES, VIDEO_LIVE } from '@server/initializers/constants.js'
|
import { DIRECTORIES, VIDEO_LIVE } from '@server/initializers/constants.js'
|
||||||
import {
|
import {
|
||||||
|
@ -8,10 +9,10 @@ import {
|
||||||
MUserImport,
|
MUserImport,
|
||||||
MVideo,
|
MVideo,
|
||||||
MVideoFile,
|
MVideoFile,
|
||||||
|
MVideoPrivacy,
|
||||||
MVideoUUID
|
MVideoUUID
|
||||||
} from '@server/types/models/index.js'
|
} from '@server/types/models/index.js'
|
||||||
import { removeFragmentedMP4Ext } from '@peertube/peertube-core-utils'
|
import { join } from 'path'
|
||||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
|
||||||
import { isVideoInPrivateDirectory } from './video-privacy.js'
|
import { isVideoInPrivateDirectory } from './video-privacy.js'
|
||||||
|
|
||||||
// ################## Video file name ##################
|
// ################## Video file name ##################
|
||||||
|
@ -34,7 +35,7 @@ export function getLiveReplayBaseDirectory (video: MVideo) {
|
||||||
return join(getLiveDirectory(video), VIDEO_LIVE.REPLAY_DIRECTORY)
|
return join(getLiveDirectory(video), VIDEO_LIVE.REPLAY_DIRECTORY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHLSDirectory (video: MVideo) {
|
export function getHLSDirectory (video: MVideoPrivacy) {
|
||||||
if (isVideoInPrivateDirectory(video.privacy)) {
|
if (isVideoInPrivateDirectory(video.privacy)) {
|
||||||
return join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, video.uuid)
|
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)
|
return join(DIRECTORIES.HLS_REDUNDANCY, video.uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHlsResolutionPlaylistFilename (videoFilename: string) {
|
export function getHLSResolutionPlaylistFilename (videoFilename: string) {
|
||||||
// Video file name already contain resolution
|
|
||||||
return removeFragmentedMP4Ext(videoFilename) + '.m3u8'
|
return removeFragmentedMP4Ext(videoFilename) + '.m3u8'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,10 +12,11 @@ import { CONFIG } from '../../initializers/config.js'
|
||||||
import { VideoFileModel } from '../../models/video/video-file.js'
|
import { VideoFileModel } from '../../models/video/video-file.js'
|
||||||
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist.js'
|
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist.js'
|
||||||
import { renameVideoFileInPlaylist, updateM3U8AndShaPlaylist } from '../hls.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 { buildNewFile } from '../video-file.js'
|
||||||
import { VideoPathManager } from '../video-path-manager.js'
|
import { VideoPathManager } from '../video-path-manager.js'
|
||||||
import { buildFFmpegVOD } from './shared/index.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
|
// Concat TS segments from a live video to a fragmented mp4 HLS playlist
|
||||||
export async function generateHlsPlaylistResolutionFromTS (options: {
|
export async function generateHlsPlaylistResolutionFromTS (options: {
|
||||||
|
@ -75,7 +76,7 @@ export async function onHLSVideoFileTranscoding (options: {
|
||||||
const { video, videoOutputPath, m3u8OutputPath, filesLockedInParent = false } = options
|
const { video, videoOutputPath, m3u8OutputPath, filesLockedInParent = false } = options
|
||||||
|
|
||||||
// Create or update the playlist
|
// Create or update the playlist
|
||||||
const playlist = await retryTransactionWrapper(() => {
|
const { playlist, generated: playlistGenerated } = await retryTransactionWrapper(() => {
|
||||||
return sequelizeTypescript.transaction(async transaction => {
|
return sequelizeTypescript.transaction(async transaction => {
|
||||||
return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction)
|
return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction)
|
||||||
})
|
})
|
||||||
|
@ -97,7 +98,7 @@ export async function onHLSVideoFileTranscoding (options: {
|
||||||
// Move playlist file
|
// Move playlist file
|
||||||
const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(
|
const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(
|
||||||
video,
|
video,
|
||||||
getHlsResolutionPlaylistFilename(newVideoFile.filename)
|
getHLSResolutionPlaylistFilename(newVideoFile.filename)
|
||||||
)
|
)
|
||||||
await move(m3u8OutputPath, resolutionPlaylistPath, { overwrite: true })
|
await move(m3u8OutputPath, resolutionPlaylistPath, { overwrite: true })
|
||||||
|
|
||||||
|
@ -127,6 +128,10 @@ export async function onHLSVideoFileTranscoding (options: {
|
||||||
|
|
||||||
const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined)
|
const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined)
|
||||||
|
|
||||||
|
if (playlistGenerated) {
|
||||||
|
await createAllCaptionPlaylistsOnFSIfNeeded(video)
|
||||||
|
}
|
||||||
|
|
||||||
await updateM3U8AndShaPlaylist(video, playlist)
|
await updateM3U8AndShaPlaylist(video, playlist)
|
||||||
|
|
||||||
return { resolutionPlaylistPath, videoFile: savedVideoFile }
|
return { resolutionPlaylistPath, videoFile: savedVideoFile }
|
||||||
|
@ -180,7 +185,7 @@ async function generateHlsPlaylistCommon (options: {
|
||||||
const videoFilename = generateHLSVideoFilename(resolution)
|
const videoFilename = generateHLSVideoFilename(resolution)
|
||||||
const videoOutputPath = join(videoTranscodedBasePath, videoFilename)
|
const videoOutputPath = join(videoTranscodedBasePath, videoFilename)
|
||||||
|
|
||||||
const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename)
|
const resolutionPlaylistFilename = getHLSResolutionPlaylistFilename(videoFilename)
|
||||||
const m3u8OutputPath = join(videoTranscodedBasePath, resolutionPlaylistFilename)
|
const m3u8OutputPath = join(videoTranscodedBasePath, resolutionPlaylistFilename)
|
||||||
|
|
||||||
const transcodeOptions: HLSTranscodeOptions | HLSFromTSTranscodeOptions = {
|
const transcodeOptions: HLSTranscodeOptions | HLSFromTSTranscodeOptions = {
|
||||||
|
|
|
@ -23,11 +23,14 @@ import { VideoModel } from '@server/models/video/video.js'
|
||||||
import {
|
import {
|
||||||
MStreamingPlaylistFiles,
|
MStreamingPlaylistFiles,
|
||||||
MThumbnail,
|
MThumbnail,
|
||||||
MVideo, MVideoAP, MVideoCaption,
|
MVideo,
|
||||||
|
MVideoAP,
|
||||||
|
MVideoCaption,
|
||||||
MVideoCaptionLanguageUrl,
|
MVideoCaptionLanguageUrl,
|
||||||
MVideoChapter,
|
MVideoChapter,
|
||||||
MVideoFile,
|
MVideoFile,
|
||||||
MVideoFullLight, MVideoLiveWithSetting,
|
MVideoFullLight,
|
||||||
|
MVideoLiveWithSetting,
|
||||||
MVideoPassword
|
MVideoPassword
|
||||||
} from '@server/types/models/index.js'
|
} from '@server/types/models/index.js'
|
||||||
import { MVideoSource } from '@server/types/models/video/video-source.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 { PassThrough, Readable } from 'stream'
|
||||||
import { AbstractUserExporter, ExportResult } from './abstract-user-exporter.js'
|
import { AbstractUserExporter, ExportResult } from './abstract-user-exporter.js'
|
||||||
|
|
||||||
export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
|
||||||
|
constructor (
|
||||||
constructor (private readonly options: ConstructorParameters<typeof AbstractUserExporter<VideoExportJSON>>[0] & {
|
private readonly options: ConstructorParameters<typeof AbstractUserExporter<VideoExportJSON>>[0] & {
|
||||||
withVideoFiles: boolean
|
withVideoFiles: boolean
|
||||||
}) {
|
}
|
||||||
|
) {
|
||||||
super(options)
|
super(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,10 +93,10 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
||||||
|
|
||||||
const live = video.isLive
|
const live = video.isLive
|
||||||
? await VideoLiveModel.loadByVideoIdWithSettings(videoId)
|
? await VideoLiveModel.loadByVideoIdWithSettings(videoId)
|
||||||
: undefined;
|
: undefined
|
||||||
|
|
||||||
// We already have captions, so we can set it to the video object
|
// 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
|
// Then fetch more attributes for AP serialization
|
||||||
const videoAP = await video.lightAPToFullAP(undefined)
|
const videoAP = await video.lightAPToFullAP(undefined)
|
||||||
|
|
||||||
|
@ -320,7 +324,7 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
||||||
const relativePathsFromJSON = {
|
const relativePathsFromJSON = {
|
||||||
videoFile: null as string,
|
videoFile: null as string,
|
||||||
thumbnail: null as string,
|
thumbnail: null as string,
|
||||||
captions: {} as { [ lang: string ]: string }
|
captions: {} as { [lang: string]: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.withVideoFiles) {
|
if (this.options.withVideoFiles) {
|
||||||
|
@ -333,7 +337,8 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
||||||
archivePath: videoPath,
|
archivePath: videoPath,
|
||||||
|
|
||||||
// Prefer using original file if possible
|
// Prefer using original file if possible
|
||||||
readStreamFactory: () => source?.keptOriginalFilename
|
readStreamFactory: () =>
|
||||||
|
source?.keptOriginalFilename
|
||||||
? this.generateVideoSourceReadStream(source)
|
? this.generateVideoSourceReadStream(source)
|
||||||
: this.generateVideoFileReadStream({ video, videoFile, separatedAudioFile })
|
: this.generateVideoFileReadStream({ video, videoFile, separatedAudioFile })
|
||||||
})
|
})
|
||||||
|
@ -407,7 +412,7 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
||||||
|
|
||||||
private async generateCaptionReadStream (caption: MVideoCaption): Promise<Readable> {
|
private async generateCaptionReadStream (caption: MVideoCaption): Promise<Readable> {
|
||||||
if (caption.storage === FileStorage.FILE_SYSTEM) {
|
if (caption.storage === FileStorage.FILE_SYSTEM) {
|
||||||
return createReadStream(caption.getFSPath())
|
return createReadStream(caption.getFSFilePath())
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stream } = await getCaptionReadStream({ filename: caption.filename, rangeHeader: undefined })
|
const { stream } = await getCaptionReadStream({ filename: caption.filename, rangeHeader: undefined })
|
||||||
|
|
|
@ -37,7 +37,7 @@ import { LocalVideoCreator, ThumbnailOptions } from '@server/lib/local-video-cre
|
||||||
import { isLocalVideoFileAccepted } from '@server/lib/moderation.js'
|
import { isLocalVideoFileAccepted } from '@server/lib/moderation.js'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||||
import { isUserQuotaValid } from '@server/lib/user.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 { buildNextVideoState } from '@server/lib/video-state.js'
|
||||||
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
||||||
import { VideoModel } from '@server/models/video/video.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')
|
const lTags = loggerTagsFactory('user-import')
|
||||||
|
|
||||||
type ImportObject = VideoExportJSON['videos'][0]
|
type ImportObject = VideoExportJSON['videos'][0]
|
||||||
type SanitizedObject = Pick<ImportObject, 'name' | 'duration' | 'channel' | 'privacy' | 'archiveFiles' | 'captions' | 'category' |
|
type SanitizedObject = Pick<
|
||||||
'licence' | 'language' | 'description' | 'support' | 'nsfw' | 'isLive' | 'commentsPolicy' | 'downloadEnabled' | 'waitTranscoding' |
|
ImportObject,
|
||||||
'originallyPublishedAt' | 'tags' | 'live' | 'passwords' | 'source' | 'chapters'>
|
| 'name'
|
||||||
|
| 'duration'
|
||||||
export class VideosImporter extends AbstractUserImporter <VideoExportJSON, ImportObject, SanitizedObject> {
|
| '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) {
|
protected getImportObjects (json: VideoExportJSON) {
|
||||||
return json.videos
|
return json.videos
|
||||||
}
|
}
|
||||||
|
@ -257,6 +278,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
|
||||||
|
|
||||||
private async importCaptions (video: MVideoFullLight, videoImportData: SanitizedObject) {
|
private async importCaptions (video: MVideoFullLight, videoImportData: SanitizedObject) {
|
||||||
const captionPaths: string[] = []
|
const captionPaths: string[] = []
|
||||||
|
let updateHLS = false
|
||||||
|
|
||||||
for (const captionImport of videoImportData.captions) {
|
for (const captionImport of videoImportData.captions) {
|
||||||
const relativeFilePath = videoImportData.archiveFiles?.captions?.[captionImport.language]
|
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
|
if (!await this.isFileValidOrLog(absoluteFilePath, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)) continue
|
||||||
|
|
||||||
await createLocalCaption({
|
const caption = await createLocalCaption({
|
||||||
video,
|
video,
|
||||||
language: captionImport.language,
|
language: captionImport.language,
|
||||||
path: absoluteFilePath,
|
path: absoluteFilePath,
|
||||||
|
@ -278,6 +300,12 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
|
||||||
})
|
})
|
||||||
|
|
||||||
captionPaths.push(absoluteFilePath)
|
captionPaths.push(absoluteFilePath)
|
||||||
|
|
||||||
|
if (caption.m3u8Filename) updateHLS = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateHLS && video.getHLSPlaylist()) {
|
||||||
|
await updateHLSMasterOnCaptionChange(video, video.getHLSPlaylist())
|
||||||
}
|
}
|
||||||
|
|
||||||
return captionPaths
|
return captionPaths
|
||||||
|
|
|
@ -3,23 +3,26 @@ import { buildSUUID } from '@peertube/peertube-node-utils'
|
||||||
import { AbstractTranscriber, TranscriptionModel, WhisperBuiltinModel, transcriberFactory } from '@peertube/peertube-transcription'
|
import { AbstractTranscriber, TranscriptionModel, WhisperBuiltinModel, transcriberFactory } from '@peertube/peertube-transcription'
|
||||||
import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils.js'
|
import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils.js'
|
||||||
import { isVideoCaptionLanguageValid } from '@server/helpers/custom-validators/video-captions.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 { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||||
import { CONFIG } from '@server/initializers/config.js'
|
import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { DIRECTORIES } from '@server/initializers/constants.js'
|
import { DIRECTORIES } from '@server/initializers/constants.js'
|
||||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||||
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.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 { 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 { MutexInterface } from 'async-mutex'
|
||||||
import { ensureDir, remove } from 'fs-extra/esm'
|
import { ensureDir, remove } from 'fs-extra/esm'
|
||||||
|
import { writeFile } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { federateVideoIfNeeded } from './activitypub/videos/federate.js'
|
import { federateVideoIfNeeded } from './activitypub/videos/federate.js'
|
||||||
|
import { buildCaptionM3U8Content, updateM3U8AndShaPlaylist } from './hls.js'
|
||||||
import { JobQueue } from './job-queue/job-queue.js'
|
import { JobQueue } from './job-queue/job-queue.js'
|
||||||
import { Notifier } from './notifier/notifier.js'
|
import { Notifier } from './notifier/notifier.js'
|
||||||
import { TranscriptionJobHandler } from './runners/index.js'
|
import { TranscriptionJobHandler } from './runners/index.js'
|
||||||
import { VideoPathManager } from './video-path-manager.js'
|
import { VideoPathManager } from './video-path-manager.js'
|
||||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('video-caption')
|
const lTags = loggerTagsFactory('video-caption')
|
||||||
|
|
||||||
|
@ -41,6 +44,13 @@ export async function createLocalCaption (options: {
|
||||||
|
|
||||||
await moveAndProcessCaptionFile({ path }, videoCaption)
|
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(() => {
|
await retryTransactionWrapper(() => {
|
||||||
return sequelizeTypescript.transaction(t => {
|
return sequelizeTypescript.transaction(t => {
|
||||||
return VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
|
return VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
|
||||||
|
@ -56,6 +66,41 @@ export async function createLocalCaption (options: {
|
||||||
return Object.assign(videoCaption, { Video: video })
|
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) {
|
export async function createTranscriptionTaskIfNeeded (video: MVideoUUID & MVideoUrl) {
|
||||||
if (CONFIG.VIDEO_TRANSCRIPTION.ENABLED !== true) return
|
if (CONFIG.VIDEO_TRANSCRIPTION.ENABLED !== true) return
|
||||||
|
|
||||||
|
@ -186,6 +231,10 @@ export async function onTranscriptionEnded (options: {
|
||||||
automaticallyGenerated: true
|
automaticallyGenerated: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (caption.m3u8Filename) {
|
||||||
|
await updateHLSMasterOnCaptionChangeIfNeeded(video)
|
||||||
|
}
|
||||||
|
|
||||||
await sequelizeTypescript.transaction(async t => {
|
await sequelizeTypescript.transaction(async t => {
|
||||||
await federateVideoIfNeeded(video, false, 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))
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -90,7 +90,7 @@ export async function removeHLSPlaylist (video: MVideoWithAllFiles) {
|
||||||
const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
|
const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await video.removeStreamingPlaylistFiles(hls)
|
await video.removeAllStreamingPlaylistFiles({ playlist: hls })
|
||||||
await hls.destroy()
|
await hls.destroy()
|
||||||
|
|
||||||
video.VideoStreamingPlaylists = video.VideoStreamingPlaylists.filter(p => p.id !== hls.id)
|
video.VideoStreamingPlaylists = video.VideoStreamingPlaylists.filter(p => p.id !== hls.id)
|
||||||
|
|
|
@ -11,23 +11,23 @@ import {
|
||||||
MVideoFile,
|
MVideoFile,
|
||||||
MVideoFileStreamingPlaylistVideo,
|
MVideoFileStreamingPlaylistVideo,
|
||||||
MVideoFileVideo,
|
MVideoFileVideo,
|
||||||
|
MVideoPrivacy,
|
||||||
MVideoWithFile
|
MVideoWithFile
|
||||||
} from '@server/types/models/index.js'
|
} from '@server/types/models/index.js'
|
||||||
import { Mutex } from 'async-mutex'
|
import { Mutex } from 'async-mutex'
|
||||||
import { remove } from 'fs-extra/esm'
|
import { remove } from 'fs-extra/esm'
|
||||||
import { extname, join } from 'path'
|
import { extname, join } from 'path'
|
||||||
import { makeHLSFileAvailable, makeWebVideoFileAvailable } from './object-storage/index.js'
|
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'
|
import { isVideoInPrivateDirectory } from './video-privacy.js'
|
||||||
|
|
||||||
type MakeAvailableCB <T> = (path: string) => Awaitable<T>
|
type MakeAvailableCB<T> = (path: string) => Awaitable<T>
|
||||||
type MakeAvailableMultipleCB <T> = (paths: string[]) => Awaitable<T>
|
type MakeAvailableMultipleCB<T> = (paths: string[]) => Awaitable<T>
|
||||||
type MakeAvailableCreateMethod = { method: () => Awaitable<string>, clean: boolean }
|
type MakeAvailableCreateMethod = { method: () => Awaitable<string>, clean: boolean }
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('video-path-manager')
|
const lTags = loggerTagsFactory('video-path-manager')
|
||||||
|
|
||||||
class VideoPathManager {
|
class VideoPathManager {
|
||||||
|
|
||||||
private static instance: VideoPathManager
|
private static instance: VideoPathManager
|
||||||
|
|
||||||
// Key is a video UUID
|
// Key is a video UUID
|
||||||
|
@ -35,7 +35,7 @@ class VideoPathManager {
|
||||||
|
|
||||||
private constructor () {}
|
private constructor () {}
|
||||||
|
|
||||||
getFSHLSOutputPath (video: MVideo, filename?: string) {
|
getFSHLSOutputPath (video: MVideoPrivacy, filename?: string) {
|
||||||
const base = getHLSDirectory(video)
|
const base = getHLSDirectory(video)
|
||||||
if (!filename) return base
|
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[] = []
|
const createMethods: MakeAvailableCreateMethod[] = []
|
||||||
|
|
||||||
for (const videoFile of videoFiles) {
|
for (const videoFile of videoFiles) {
|
||||||
|
@ -95,11 +95,11 @@ class VideoPathManager {
|
||||||
return this.makeAvailableFactory({ createMethods, cbContext: cb })
|
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]))
|
return this.makeAvailableVideoFiles([ videoFile ], paths => cb(paths[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
async makeAvailableMaxQualityFiles <T> (
|
async makeAvailableMaxQualityFiles<T> (
|
||||||
video: MVideoWithFile,
|
video: MVideoWithFile,
|
||||||
cb: (options: { videoPath: string, separatedAudioPath: string }) => Awaitable<T>
|
cb: (options: { videoPath: string, separatedAudioPath: string }) => Awaitable<T>
|
||||||
) {
|
) {
|
||||||
|
@ -115,8 +115,8 @@ class VideoPathManager {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async makeAvailableResolutionPlaylistFile <T> (videoFile: MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
|
async makeAvailableResolutionPlaylistFile<T> (videoFile: MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
|
||||||
const filename = getHlsResolutionPlaylistFilename(videoFile.filename)
|
const filename = getHLSResolutionPlaylistFilename(videoFile.filename)
|
||||||
|
|
||||||
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
|
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
|
||||||
return this.makeAvailableFactory({
|
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) {
|
if (playlist.storage === FileStorage.FILE_SYSTEM) {
|
||||||
return this.makeAvailableFactory({
|
return this.makeAvailableFactory({
|
||||||
createMethods: [
|
createMethods: [
|
||||||
|
@ -189,7 +189,7 @@ class VideoPathManager {
|
||||||
logger.debug('Released lockfiles of %s.', videoUUID, lTags(videoUUID))
|
logger.debug('Released lockfiles of %s.', videoUUID, lTags(videoUUID))
|
||||||
}
|
}
|
||||||
|
|
||||||
private async makeAvailableFactory <T> (options: {
|
private async makeAvailableFactory<T> (options: {
|
||||||
createMethods: MakeAvailableCreateMethod[]
|
createMethods: MakeAvailableCreateMethod[]
|
||||||
cbContext: MakeAvailableMultipleCB<T>
|
cbContext: MakeAvailableMultipleCB<T>
|
||||||
}) {
|
}) {
|
||||||
|
|
|
@ -20,7 +20,8 @@ import {
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
DataType,
|
DataType,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Is, Scopes,
|
Is,
|
||||||
|
Scopes,
|
||||||
Table,
|
Table,
|
||||||
UpdatedAt
|
UpdatedAt
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
|
@ -57,7 +58,6 @@ export enum ScopeNames {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@Table({
|
@Table({
|
||||||
tableName: 'videoRedundancy',
|
tableName: 'videoRedundancy',
|
||||||
indexes: [
|
indexes: [
|
||||||
|
@ -77,7 +77,6 @@ export enum ScopeNames {
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VideoRedundancyModel extends SequelizeModel<VideoRedundancyModel> {
|
export class VideoRedundancyModel extends SequelizeModel<VideoRedundancyModel> {
|
||||||
|
|
||||||
@CreatedAt
|
@CreatedAt
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
|
|
||||||
|
@ -134,7 +133,7 @@ export class VideoRedundancyModel extends SequelizeModel<VideoRedundancyModel> {
|
||||||
const videoUUID = videoStreamingPlaylist.Video.uuid
|
const videoUUID = videoStreamingPlaylist.Video.uuid
|
||||||
logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
|
logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
|
||||||
|
|
||||||
videoStreamingPlaylist.Video.removeStreamingPlaylistFiles(videoStreamingPlaylist, true)
|
videoStreamingPlaylist.Video.removeAllStreamingPlaylistFiles({ playlist: videoStreamingPlaylist, isRedundancy: true })
|
||||||
.catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
|
.catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
import { uuidToShort } from '@peertube/peertube-node-utils'
|
import { uuidToShort } from '@peertube/peertube-node-utils'
|
||||||
import { generateMagnetUri } from '@server/helpers/webtorrent.js'
|
import { generateMagnetUri } from '@server/helpers/webtorrent.js'
|
||||||
import { tracer } from '@server/lib/opentelemetry/tracing.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 { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js'
|
||||||
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
|
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
|
||||||
import { isArray } from '../../../helpers/custom-validators/misc.js'
|
import { isArray } from '../../../helpers/custom-validators/misc.js'
|
||||||
|
@ -277,7 +277,7 @@ export function videoFilesModelToFormattedJSON (
|
||||||
hasVideo: videoFile.hasVideo(),
|
hasVideo: videoFile.hasVideo(),
|
||||||
|
|
||||||
playlistUrl: includePlaylistUrl === true
|
playlistUrl: includePlaylistUrl === true
|
||||||
? getHlsResolutionPlaylistFilename(fileUrl)
|
? getHLSResolutionPlaylistFilename(fileUrl)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
storage: video.remote
|
storage: video.remote
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
|
import { removeVTTExt } from '@peertube/peertube-core-utils'
|
||||||
import { FileStorage, type FileStorageType, VideoCaption, VideoCaptionObject } from '@peertube/peertube-models'
|
import { FileStorage, type FileStorageType, VideoCaption, VideoCaptionObject } from '@peertube/peertube-models'
|
||||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||||
import { getObjectStoragePublicFileUrl } from '@server/lib/object-storage/urls.js'
|
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 {
|
import {
|
||||||
MVideo,
|
MVideo,
|
||||||
MVideoCaption,
|
MVideoCaption,
|
||||||
|
MVideoCaptionFilename,
|
||||||
MVideoCaptionFormattable,
|
MVideoCaptionFormattable,
|
||||||
MVideoCaptionLanguageUrl,
|
MVideoCaptionLanguageUrl,
|
||||||
|
MVideoCaptionUrl,
|
||||||
MVideoCaptionVideo,
|
MVideoCaptionVideo,
|
||||||
MVideoOwned
|
MVideoOwned,
|
||||||
|
MVideoPrivacy
|
||||||
} from '@server/types/models/index.js'
|
} from '@server/types/models/index.js'
|
||||||
import { remove } from 'fs-extra/esm'
|
import { remove } from 'fs-extra/esm'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
@ -22,7 +27,8 @@ import {
|
||||||
DataType,
|
DataType,
|
||||||
Default,
|
Default,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Is, Scopes,
|
Is,
|
||||||
|
Scopes,
|
||||||
Table,
|
Table,
|
||||||
UpdatedAt
|
UpdatedAt
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
|
@ -31,13 +37,14 @@ import { logger } from '../../helpers/logger.js'
|
||||||
import { CONFIG } from '../../initializers/config.js'
|
import { CONFIG } from '../../initializers/config.js'
|
||||||
import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants.js'
|
import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants.js'
|
||||||
import { SequelizeModel, buildWhereIdOrUUID, doesExist, throwIfNotValid } from '../shared/index.js'
|
import { SequelizeModel, buildWhereIdOrUUID, doesExist, throwIfNotValid } from '../shared/index.js'
|
||||||
|
import { VideoStreamingPlaylistModel } from './video-streaming-playlist.js'
|
||||||
import { VideoModel } from './video.js'
|
import { VideoModel } from './video.js'
|
||||||
|
|
||||||
export enum ScopeNames {
|
export enum ScopeNames {
|
||||||
CAPTION_WITH_VIDEO = 'CAPTION_WITH_VIDEO'
|
CAPTION_WITH_VIDEO = 'CAPTION_WITH_VIDEO'
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoAttributes = [ 'id', 'name', 'remote', 'uuid', 'url', 'state' ]
|
const videoAttributes = [ 'id', 'name', 'remote', 'uuid', 'url', 'state', 'privacy' ]
|
||||||
|
|
||||||
@Scopes(() => ({
|
@Scopes(() => ({
|
||||||
[ScopeNames.CAPTION_WITH_VIDEO]: {
|
[ScopeNames.CAPTION_WITH_VIDEO]: {
|
||||||
|
@ -50,7 +57,6 @@ const videoAttributes = [ 'id', 'name', 'remote', 'uuid', 'url', 'state' ]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@Table({
|
@Table({
|
||||||
tableName: 'videoCaption',
|
tableName: 'videoCaption',
|
||||||
indexes: [
|
indexes: [
|
||||||
|
@ -83,6 +89,10 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
|
||||||
@Column
|
@Column
|
||||||
filename: string
|
filename: string
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Column
|
||||||
|
m3u8Filename: string
|
||||||
|
|
||||||
@AllowNull(false)
|
@AllowNull(false)
|
||||||
@Default(FileStorage.FILE_SYSTEM)
|
@Default(FileStorage.FILE_SYSTEM)
|
||||||
@Column
|
@Column
|
||||||
|
@ -92,6 +102,10 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
|
||||||
@Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
|
@Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
|
||||||
fileUrl: string
|
fileUrl: string
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Column
|
||||||
|
m3u8Url: string
|
||||||
|
|
||||||
@AllowNull(false)
|
@AllowNull(false)
|
||||||
@Column
|
@Column
|
||||||
automaticallyGenerated: boolean
|
automaticallyGenerated: boolean
|
||||||
|
@ -117,11 +131,8 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
|
||||||
if (instance.isOwned()) {
|
if (instance.isOwned()) {
|
||||||
logger.info('Removing caption %s.', instance.filename)
|
logger.info('Removing caption %s.', instance.filename)
|
||||||
|
|
||||||
try {
|
instance.removeAllCaptionFiles()
|
||||||
await instance.removeCaptionFile()
|
.catch(err => logger.error('Cannot remove caption file ' + instance.filename, { err }))
|
||||||
} catch (err) {
|
|
||||||
logger.error('Cannot remove caption file %s.', instance.filename)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
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 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) {
|
for (const id of videoIds) {
|
||||||
result[id] = []
|
result[id] = []
|
||||||
|
@ -253,6 +264,10 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
|
||||||
return `${buildUUID()}-${language}.vtt`
|
return `${buildUUID()}-${language}.vtt`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static generateM3U8Filename (vttFilename: string) {
|
||||||
|
return removeVTTExt(vttFilename) + '.m3u8'
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
toFormattedJSON (this: MVideoCaptionFormattable): VideoCaption {
|
toFormattedJSON (this: MVideoCaptionFormattable): VideoCaption {
|
||||||
|
@ -265,9 +280,10 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
|
||||||
|
|
||||||
captionPath: this.Video.isOwned() && this.fileUrl
|
captionPath: this.Video.isOwned() && this.fileUrl
|
||||||
? null // On object storage
|
? null // On object storage
|
||||||
: this.getCaptionStaticPath(),
|
: this.getFileStaticPath(),
|
||||||
|
|
||||||
fileUrl: this.getFileUrl(this.Video),
|
fileUrl: this.getFileUrl(this.Video),
|
||||||
|
m3u8Url: this.getM3U8Url(this.Video),
|
||||||
|
|
||||||
updatedAt: this.updatedAt.toISOString()
|
updatedAt: this.updatedAt.toISOString()
|
||||||
}
|
}
|
||||||
|
@ -278,7 +294,22 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
|
||||||
identifier: this.language,
|
identifier: this.language,
|
||||||
name: VideoCaptionModel.getLanguageLabel(this.language),
|
name: VideoCaptionModel.getLanguageLabel(this.language),
|
||||||
automaticallyGenerated: this.automaticallyGenerated,
|
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
|
return this.Video.remote === false
|
||||||
}
|
}
|
||||||
|
|
||||||
getCaptionStaticPath (this: MVideoCaptionLanguageUrl) {
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getFileStaticPath (this: MVideoCaptionFilename) {
|
||||||
return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
|
return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
getFSPath () {
|
getM3U8StaticPath (this: MVideoCaptionFilename, video: MVideoPrivacy) {
|
||||||
return join(CONFIG.STORAGE.CAPTIONS_DIR, this.filename)
|
if (!this.m3u8Filename) return null
|
||||||
}
|
|
||||||
|
|
||||||
removeCaptionFile (this: MVideoCaption) {
|
return VideoStreamingPlaylistModel.getPlaylistFileStaticPath(video, this.m3u8Filename)
|
||||||
if (this.storage === FileStorage.OBJECT_STORAGE) {
|
|
||||||
return removeCaptionObjectStorage(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
return remove(this.getFSPath())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
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) {
|
if (video.isOwned() && this.storage === FileStorage.OBJECT_STORAGE) {
|
||||||
return getObjectStoragePublicFileUrl(this.fileUrl, CONFIG.OBJECT_STORAGE.CAPTIONS)
|
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)
|
if (video.isOwned()) return this.getFileUrl(video)
|
||||||
|
|
||||||
return this.fileUrl
|
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) {
|
isEqual (this: MVideoCaption, other: MVideoCaption) {
|
||||||
if (this.fileUrl) return this.fileUrl === other.fileUrl
|
if (this.fileUrl) return this.fileUrl === other.fileUrl
|
||||||
|
|
||||||
|
|
|
@ -12,22 +12,18 @@ import { getHLSPrivateFileUrl, getObjectStoragePublicFileUrl } from '@server/lib
|
||||||
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths.js'
|
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths.js'
|
||||||
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
|
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
|
||||||
import { VideoFileModel } from '@server/models/video/video-file.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 memoizee from 'memoizee'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { Op, Transaction } from 'sequelize'
|
import { Op, Transaction } from 'sequelize'
|
||||||
import {
|
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, Is, Table, UpdatedAt } from 'sequelize-typescript'
|
||||||
AllowNull,
|
|
||||||
BelongsTo,
|
|
||||||
Column,
|
|
||||||
CreatedAt,
|
|
||||||
DataType,
|
|
||||||
Default,
|
|
||||||
ForeignKey,
|
|
||||||
HasMany,
|
|
||||||
Is, Table,
|
|
||||||
UpdatedAt
|
|
||||||
} from 'sequelize-typescript'
|
|
||||||
import { isArrayOf } from '../../helpers/custom-validators/misc.js'
|
import { isArrayOf } from '../../helpers/custom-validators/misc.js'
|
||||||
import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos.js'
|
import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos.js'
|
||||||
import {
|
import {
|
||||||
|
@ -205,7 +201,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
|
||||||
return VideoStreamingPlaylistModel.findByPk(id, options)
|
return VideoStreamingPlaylistModel.findByPk(id, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
static loadHLSPlaylistByVideo (videoId: number, transaction?: Transaction): Promise<MStreamingPlaylist> {
|
static loadHLSByVideo (videoId: number, transaction?: Transaction): Promise<MStreamingPlaylist> {
|
||||||
const options = {
|
const options = {
|
||||||
where: {
|
where: {
|
||||||
type: VideoStreamingPlaylistType.HLS,
|
type: VideoStreamingPlaylistType.HLS,
|
||||||
|
@ -217,10 +213,31 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
|
||||||
return VideoStreamingPlaylistModel.findOne(options)
|
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) {
|
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) {
|
if (!playlist) {
|
||||||
|
generated = true
|
||||||
|
|
||||||
playlist = new VideoStreamingPlaylistModel({
|
playlist = new VideoStreamingPlaylistModel({
|
||||||
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
|
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
|
||||||
type: VideoStreamingPlaylistType.HLS,
|
type: VideoStreamingPlaylistType.HLS,
|
||||||
|
@ -234,7 +251,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
|
||||||
await playlist.save({ transaction })
|
await playlist.save({ transaction })
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.assign(playlist, { Video: video })
|
return { generated, playlist: Object.assign(playlist, { Video: video }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static doesOwnedVideoUUIDExist (videoUUID: string, storage: FileStorageType) {
|
static doesOwnedVideoUUIDExist (videoUUID: string, storage: FileStorageType) {
|
||||||
|
@ -339,19 +356,21 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
|
||||||
return Object.assign(this, { Video: video })
|
return Object.assign(this, { Video: video })
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMasterPlaylistStaticPath (video: MVideo) {
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static getPlaylistFileStaticPath (video: MVideoPrivacy, filename: string) {
|
||||||
if (isVideoInPrivateDirectory(video.privacy)) {
|
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) {
|
private getMasterPlaylistStaticPath (video: MVideoPrivacy) {
|
||||||
if (isVideoInPrivateDirectory(video.privacy)) {
|
return VideoStreamingPlaylistModel.getPlaylistFileStaticPath(video, this.playlistFilename)
|
||||||
return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.segmentsSha256Filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.segmentsSha256Filename)
|
private getSha256SegmentsStaticPath (video: MVideoPrivacy) {
|
||||||
|
return VideoStreamingPlaylistModel.getPlaylistFileStaticPath(video, this.segmentsSha256Filename)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ import {
|
||||||
removeWebVideoObjectStorage
|
removeWebVideoObjectStorage
|
||||||
} from '@server/lib/object-storage/index.js'
|
} from '@server/lib/object-storage/index.js'
|
||||||
import { tracer } from '@server/lib/opentelemetry/tracing.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 { Hooks } from '@server/lib/plugins/hooks.js'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||||
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
|
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
|
||||||
|
@ -640,7 +640,6 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
||||||
name: 'videoId',
|
name: 'videoId',
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
hooks: true,
|
|
||||||
onDelete: 'cascade'
|
onDelete: 'cascade'
|
||||||
})
|
})
|
||||||
VideoFiles: Awaited<VideoFileModel>[]
|
VideoFiles: Awaited<VideoFileModel>[]
|
||||||
|
@ -650,7 +649,6 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
||||||
name: 'videoId',
|
name: 'videoId',
|
||||||
allowNull: false
|
allowNull: false
|
||||||
},
|
},
|
||||||
hooks: true,
|
|
||||||
onDelete: 'cascade'
|
onDelete: 'cascade'
|
||||||
})
|
})
|
||||||
VideoStreamingPlaylists: Awaited<VideoStreamingPlaylistModel>[]
|
VideoStreamingPlaylists: Awaited<VideoStreamingPlaylistModel>[]
|
||||||
|
@ -834,7 +832,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
||||||
static async removeFiles (instance: VideoModel, options) {
|
static async removeFiles (instance: VideoModel, options) {
|
||||||
const tasks: Promise<any>[] = []
|
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 (instance.isOwned()) {
|
||||||
if (!Array.isArray(instance.VideoFiles)) {
|
if (!Array.isArray(instance.VideoFiles)) {
|
||||||
|
@ -852,7 +850,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const p of instance.VideoStreamingPlaylists) {
|
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
|
// Remove source files
|
||||||
|
@ -1904,7 +1903,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
||||||
if (isArray(videoAP.VideoCaptions)) return videoAP.VideoCaptions
|
if (isArray(videoAP.VideoCaptions)) return videoAP.VideoCaptions
|
||||||
|
|
||||||
return this.$get('VideoCaptions', {
|
return this.$get('VideoCaptions', {
|
||||||
attributes: [ 'filename', 'language', 'fileUrl', 'storage', 'automaticallyGenerated' ],
|
attributes: [ 'filename', 'language', 'fileUrl', 'storage', 'automaticallyGenerated', 'm3u8Filename', 'm3u8Url' ],
|
||||||
transaction
|
transaction
|
||||||
}) as Promise<MVideoCaptionLanguageUrl[]>
|
}) as Promise<MVideoCaptionLanguageUrl[]>
|
||||||
}
|
}
|
||||||
|
@ -1993,11 +1992,18 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
||||||
return Promise.all(promises)
|
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
|
const directoryPath = isRedundancy
|
||||||
? getHLSRedundancyDirectory(this)
|
? getHLSRedundancyDirectory(this)
|
||||||
: getHLSDirectory(this)
|
: getHLSDirectory(this)
|
||||||
|
|
||||||
|
const removeDirectory = async () => {
|
||||||
try {
|
try {
|
||||||
await remove(directoryPath)
|
await remove(directoryPath)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -2012,28 +2018,50 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
||||||
|
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isRedundancy !== true) {
|
if (isRedundancy) {
|
||||||
const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
|
await removeDirectory()
|
||||||
streamingPlaylistWithFiles.Video = this
|
} else {
|
||||||
|
if (deleteCaptionPlaylists) {
|
||||||
|
const captions = await VideoCaptionModel.listVideoCaptions(playlist.videoId)
|
||||||
|
|
||||||
if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
|
// Remove playlist files associated to captions
|
||||||
streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
|
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
|
// Remove physical files and torrents
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
|
playlistWithFiles.VideoFiles.map(file => file.removeTorrent())
|
||||||
)
|
)
|
||||||
|
|
||||||
if (streamingPlaylist.storage === FileStorage.OBJECT_STORAGE) {
|
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
|
||||||
await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
|
await removeHLSObjectStorage(playlist.withVideo(this))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Removing files associated to streaming playlist of video ${this.url}`,
|
`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 videoFile.removeTorrent()
|
||||||
await remove(filePath)
|
await remove(filePath)
|
||||||
|
|
||||||
const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
|
const resolutionFilename = getHLSResolutionPlaylistFilename(videoFile.filename)
|
||||||
await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
|
await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
|
||||||
|
|
||||||
if (videoFile.storage === FileStorage.OBJECT_STORAGE) {
|
if (videoFile.storage === FileStorage.OBJECT_STORAGE) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { PickWith } from '@peertube/peertube-typescript-utils'
|
import { PickWith } from '@peertube/peertube-typescript-utils'
|
||||||
import { VideoCaptionModel } from '../../../models/video/video-caption.js'
|
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>
|
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 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<
|
export type MVideoCaptionLanguageUrl = Pick<
|
||||||
MVideoCaption,
|
MVideoCaption,
|
||||||
| 'language'
|
| 'language'
|
||||||
|
@ -18,15 +25,19 @@ export type MVideoCaptionLanguageUrl = Pick<
|
||||||
| 'storage'
|
| 'storage'
|
||||||
| 'filename'
|
| 'filename'
|
||||||
| 'automaticallyGenerated'
|
| 'automaticallyGenerated'
|
||||||
| 'getFileUrl'
|
| 'm3u8Filename'
|
||||||
| 'getCaptionStaticPath'
|
| 'm3u8Url'
|
||||||
| 'toActivityPubObject'
|
| 'toActivityPubObject'
|
||||||
|
| 'getFileUrl'
|
||||||
|
| 'getFileStaticPath'
|
||||||
| 'getOriginFileUrl'
|
| 'getOriginFileUrl'
|
||||||
|
| 'getM3U8Url'
|
||||||
|
| 'getM3U8StaticPath'
|
||||||
>
|
>
|
||||||
|
|
||||||
export type MVideoCaptionVideo =
|
export type MVideoCaptionVideo =
|
||||||
& MVideoCaption
|
& 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 =
|
export type MVideoCaptionFormattable =
|
||||||
& MVideoCaption
|
& MVideoCaption
|
||||||
& Pick<MVideoCaption, 'language'>
|
& Pick<MVideoCaption, 'language'>
|
||||||
& Use<'Video', MVideoOwned & MVideoUUID>
|
& Use<'Video', MVideoOwned & MVideoPrivacy>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils'
|
import { PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils'
|
||||||
import { VideoStreamingPlaylistModel } from '../../../models/video/video-streaming-playlist.js'
|
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 { MVideoFile } from './video-file.js'
|
||||||
import { MVideoRedundancy, MVideoRedundancyFileUrl } from './video-redundancy.js'
|
import { MVideoRedundancy, MVideoRedundancyFileUrl } from './video-redundancy.js'
|
||||||
|
|
||||||
|
@ -18,6 +18,10 @@ export type MStreamingPlaylistVideo =
|
||||||
& MStreamingPlaylist
|
& MStreamingPlaylist
|
||||||
& Use<'Video', MVideo>
|
& Use<'Video', MVideo>
|
||||||
|
|
||||||
|
export type MStreamingPlaylistVideoUUID =
|
||||||
|
& MStreamingPlaylist
|
||||||
|
& Use<'Video', MVideoUUID>
|
||||||
|
|
||||||
export type MStreamingPlaylistFilesVideo =
|
export type MStreamingPlaylistFilesVideo =
|
||||||
& MStreamingPlaylist
|
& MStreamingPlaylist
|
||||||
& Use<'VideoFiles', MVideoFile[]>
|
& Use<'VideoFiles', MVideoFile[]>
|
||||||
|
|
|
@ -61,6 +61,7 @@ export type MVideo = Omit<
|
||||||
export type MVideoId = Pick<MVideo, 'id'>
|
export type MVideoId = Pick<MVideo, 'id'>
|
||||||
export type MVideoUrl = Pick<MVideo, 'url'>
|
export type MVideoUrl = Pick<MVideo, 'url'>
|
||||||
export type MVideoUUID = Pick<MVideo, 'uuid'>
|
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 MVideoImmutable = Pick<MVideo, 'id' | 'url' | 'uuid' | 'remote' | 'isOwned'>
|
||||||
export type MVideoOwned = Pick<MVideo, 'remote' | 'isOwned'>
|
export type MVideoOwned = Pick<MVideo, 'remote' | 'isOwned'>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { JobQueue } from '@server/lib/job-queue/index.js'
|
||||||
import {
|
import {
|
||||||
generateHLSMasterPlaylistFilename,
|
generateHLSMasterPlaylistFilename,
|
||||||
generateHlsSha256SegmentsFilename,
|
generateHlsSha256SegmentsFilename,
|
||||||
getHlsResolutionPlaylistFilename
|
getHLSResolutionPlaylistFilename
|
||||||
} from '@server/lib/paths.js'
|
} from '@server/lib/paths.js'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||||
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
||||||
|
@ -19,7 +19,6 @@ run()
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
process.exit(-1)
|
process.exit(-1)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function run () {
|
async function run () {
|
||||||
|
@ -52,7 +51,7 @@ async function processVideo (videoId: number) {
|
||||||
|
|
||||||
console.log(`Renaming HLS playlist files of video ${video.name}.`)
|
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 hlsDirPath = VideoPathManager.Instance.getFSHLSOutputPath(video)
|
||||||
|
|
||||||
const masterPlaylistPath = join(hlsDirPath, playlist.playlistFilename)
|
const masterPlaylistPath = join(hlsDirPath, playlist.playlistFilename)
|
||||||
|
@ -60,7 +59,7 @@ async function processVideo (videoId: number) {
|
||||||
|
|
||||||
for (const videoFile of hls.VideoFiles) {
|
for (const videoFile of hls.VideoFiles) {
|
||||||
const srcName = `${videoFile.resolution}.m3u8`
|
const srcName = `${videoFile.resolution}.m3u8`
|
||||||
const dstName = getHlsResolutionPlaylistFilename(videoFile.filename)
|
const dstName = getHLSResolutionPlaylistFilename(videoFile.filename)
|
||||||
|
|
||||||
const src = join(hlsDirPath, srcName)
|
const src = join(hlsDirPath, srcName)
|
||||||
const dst = join(hlsDirPath, dstName)
|
const dst = join(hlsDirPath, dstName)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue