From 6e44e7e29aa02e6aa067b1cc52966b3dd94ece3a Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 8 Apr 2025 15:26:02 +0200 Subject: [PATCH] Create and inject caption playlist in HLS master --- .../app/core/rest/rest-extractor.service.ts | 1 - .../player/src/shared/common/utils.ts | 7 +- .../hls-options-builder.ts | 17 +- packages/core-utils/src/common/regexp.ts | 4 + .../src/activitypub/objects/common-objects.ts | 16 +- .../objects/video-caption-object.ts | 4 +- .../src/videos/caption/video-caption.model.ts | 1 + packages/tests/src/api/users/user-export.ts | 21 +- packages/tests/src/api/users/user-import.ts | 37 +-- .../src/api/videos/video-captions-playlist.ts | 276 ++++++++++++++++++ .../src/api/videos/video-transcription.ts | 41 ++- .../src/cli/create-move-video-storage-job.ts | 22 +- .../peertube-runner/video-transcription.ts | 26 +- packages/tests/src/shared/import-export.ts | 16 +- .../tests/src/shared/streaming-playlists.ts | 268 ++++++++++++----- .../core/controllers/api/videos/captions.ts | 36 ++- server/core/controllers/api/videos/source.ts | 8 +- server/core/controllers/static.ts | 2 +- server/core/helpers/captions-utils.ts | 14 +- .../custom-validators/activitypub/videos.ts | 41 ++- server/core/initializers/constants.ts | 2 +- .../migrations/0890-hls-caption.ts | 34 +++ .../shared/object-to-model-attributes.ts | 35 ++- .../video-captions-simple-file-cache.ts | 5 +- server/core/lib/hls.ts | 63 +++- .../job-queue/handlers/move-to-file-system.ts | 49 +++- .../handlers/move-to-object-storage.ts | 63 +++- .../job-queue/handlers/shared/move-caption.ts | 9 +- .../job-queue/handlers/shared/move-video.ts | 9 +- server/core/lib/live/live-manager.ts | 29 +- server/core/lib/live/shared/muxing-session.ts | 34 ++- server/core/lib/object-storage/keys.ts | 6 +- server/core/lib/object-storage/videos.ts | 24 +- server/core/lib/paths.ts | 12 +- .../core/lib/transcoding/hls-transcoding.ts | 13 +- .../exporters/videos-exporter.ts | 33 ++- .../importers/videos-importer.ts | 42 ++- server/core/lib/video-captions.ts | 65 ++++- server/core/lib/video-file.ts | 2 +- server/core/lib/video-path-manager.ts | 24 +- .../models/redundancy/video-redundancy.ts | 27 +- .../video/formatter/video-api-format.ts | 4 +- server/core/models/video/video-caption.ts | 143 +++++++-- .../models/video/video-streaming-playlist.ts | 67 +++-- server/core/models/video/video.ts | 82 ++++-- .../core/types/models/video/video-caption.ts | 21 +- .../models/video/video-streaming-playlist.ts | 6 +- server/core/types/models/video/video.ts | 1 + server/scripts/migrations/peertube-4.0.ts | 7 +- 49 files changed, 1368 insertions(+), 401 deletions(-) create mode 100644 packages/tests/src/api/videos/video-captions-playlist.ts create mode 100644 server/core/initializers/migrations/0890-hls-caption.ts diff --git a/client/src/app/core/rest/rest-extractor.service.ts b/client/src/app/core/rest/rest-extractor.service.ts index 808af21e7..2adb43640 100644 --- a/client/src/app/core/rest/rest-extractor.service.ts +++ b/client/src/app/core/rest/rest-extractor.service.ts @@ -74,7 +74,6 @@ export class RestExtractor { } private buildErrorMessage (err: any) { - console.log(err) if (err.error instanceof Error) { // A client-side or network error occurred. Handle it accordingly. const errorMessage = err.error.detail || err.error.title diff --git a/client/src/standalone/player/src/shared/common/utils.ts b/client/src/standalone/player/src/shared/common/utils.ts index 721727eeb..8175c3b9d 100644 --- a/client/src/standalone/player/src/shared/common/utils.ts +++ b/client/src/standalone/player/src/shared/common/utils.ts @@ -22,5 +22,10 @@ export function getRtcConfig (stunServers: 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 } diff --git a/client/src/standalone/player/src/shared/player-options-builder/hls-options-builder.ts b/client/src/standalone/player/src/shared/player-options-builder/hls-options-builder.ts index 6a0f0dd92..7e544f7d0 100644 --- a/client/src/standalone/player/src/shared/player-options-builder/hls-options-builder.ts +++ b/client/src/standalone/player/src/shared/player-options-builder/hls-options-builder.ts @@ -6,12 +6,7 @@ import debug from 'debug' import { Level } from 'hls.js' import type { CoreConfig, StreamConfig } from 'p2p-media-loader-core' import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' -import { - HLSPluginOptions, - P2PMediaLoaderPluginOptions, - PeerTubePlayerConstructorOptions, - PeerTubePlayerLoadOptions -} from '../../types' +import { HLSPluginOptions, P2PMediaLoaderPluginOptions, PeerTubePlayerConstructorOptions, PeerTubePlayerLoadOptions } from '../../types' import { getRtcConfig, isSameOrigin } from '../common' import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' 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') type ConstructorOptions = - Pick & - Pick + & Pick + & Pick< + PeerTubePlayerLoadOptions, + 'videoPassword' | 'requiresUserAuth' | 'videoFileToken' | 'requiresPassword' | 'isLive' | 'liveOptions' | 'p2pEnabled' | 'hls' + > export class HLSOptionsBuilder { - constructor (private options: ConstructorOptions) { - } async getPluginOptions () { diff --git a/packages/core-utils/src/common/regexp.ts b/packages/core-utils/src/common/regexp.ts index 59eb87eb6..03fdee6ce 100644 --- a/packages/core-utils/src/common/regexp.ts +++ b/packages/core-utils/src/common/regexp.ts @@ -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) { return path.replace(/-fragmented.mp4$/i, '') } + +export function removeVTTExt (path: string) { + return path.replace(/\.vtt$/i, '') +} diff --git a/packages/models/src/activitypub/objects/common-objects.ts b/packages/models/src/activitypub/objects/common-objects.ts index ece6e3da9..ad2842384 100644 --- a/packages/models/src/activitypub/objects/common-objects.ts +++ b/packages/models/src/activitypub/objects/common-objects.ts @@ -3,7 +3,6 @@ import { AbusePredefinedReasonsString } from '../../moderation/abuse/abuse-reaso export interface ActivityIdentifierObject { identifier: string name: string - url?: string } export interface ActivityIconObject { @@ -49,7 +48,7 @@ export type ActivityPlaylistSegmentHashesObject = { export type ActivityVideoFileMetadataUrlObject = { type: 'Link' - rel: [ 'metadata', any ] + rel: ['metadata', any] mediaType: 'application/json' height: number width: number | null @@ -57,9 +56,15 @@ export type ActivityVideoFileMetadataUrlObject = { fps: number } +export type ActivityCaptionUrlObject = { + type: 'Link' + mediaType: 'text/vtt' + href: string +} + export type ActivityTrackerUrlObject = { type: 'Link' - rel: [ 'tracker', 'websocket' | 'http' ] + rel: ['tracker', 'websocket' | 'http'] name: string href: string } @@ -118,7 +123,7 @@ export interface ActivityFlagReasonObject { } export type ActivityTagObject = - ActivityPlaylistSegmentHashesObject + | ActivityPlaylistSegmentHashesObject | ActivityStreamingPlaylistInfohashesObject | ActivityVideoUrlObject | ActivityHashTagObject @@ -128,13 +133,14 @@ export type ActivityTagObject = | ActivityVideoFileMetadataUrlObject export type ActivityUrlObject = - ActivityVideoUrlObject + | ActivityVideoUrlObject | ActivityPlaylistUrlObject | ActivityBitTorrentUrlObject | ActivityMagnetUrlObject | ActivityHtmlUrlObject | ActivityVideoFileMetadataUrlObject | ActivityTrackerUrlObject + | ActivityCaptionUrlObject export type ActivityPubAttributedTo = { type: 'Group' | 'Person', id: string } | string diff --git a/packages/models/src/activitypub/objects/video-caption-object.ts b/packages/models/src/activitypub/objects/video-caption-object.ts index 1ced486cc..4bd33c299 100644 --- a/packages/models/src/activitypub/objects/video-caption-object.ts +++ b/packages/models/src/activitypub/objects/video-caption-object.ts @@ -1,5 +1,7 @@ -import { ActivityIdentifierObject } from './common-objects.js' +import { ActivityCaptionUrlObject, ActivityIdentifierObject, ActivityPlaylistUrlObject } from './common-objects.js' export interface VideoCaptionObject extends ActivityIdentifierObject { automaticallyGenerated: boolean + + url: string | (ActivityCaptionUrlObject | ActivityPlaylistUrlObject)[] } diff --git a/packages/models/src/videos/caption/video-caption.model.ts b/packages/models/src/videos/caption/video-caption.model.ts index f0777fb14..f279342ea 100644 --- a/packages/models/src/videos/caption/video-caption.model.ts +++ b/packages/models/src/videos/caption/video-caption.model.ts @@ -7,6 +7,7 @@ export interface VideoCaption { captionPath: string fileUrl: string + m3u8Url: string automaticallyGenerated: boolean updatedAt: string diff --git a/packages/tests/src/api/users/user-export.ts b/packages/tests/src/api/users/user-export.ts index d35177fb5..e63244323 100644 --- a/packages/tests/src/api/users/user-export.ts +++ b/packages/tests/src/api/users/user-export.ts @@ -3,7 +3,8 @@ import { wait } from '@peertube/peertube-core-utils' import { hasAudioStream, hasVideoStream } from '@peertube/peertube-ffmpeg' import { - AccountExportJSON, ActivityPubActor, + AccountExportJSON, + ActivityPubActor, ActivityPubOrderedCollection, AutoTagPoliciesJSON, BlocklistExportJSON, @@ -22,7 +23,8 @@ import { VideoChapterObject, VideoCommentObject, VideoCreateResult, - VideoExportJSON, VideoPlaylistCreateResult, + VideoExportJSON, + VideoPlaylistCreateResult, VideoPlaylistPrivacy, VideoPlaylistsExportJSON, VideoPlaylistType, @@ -31,7 +33,9 @@ import { } from '@peertube/peertube-models' import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' import { - cleanupTests, getRedirectionUrl, makeActivityPubRawRequest, + cleanupTests, + getRedirectionUrl, + makeActivityPubRawRequest, makeRawRequest, ObjectStorageCommand, PeerTubeServer, @@ -81,9 +85,9 @@ function runTest (withObjectStorage: boolean) { objectStorage = withObjectStorage ? new ObjectStorageCommand() - : undefined; + : undefined - ({ + ;({ rootId, noahId, remoteRootId, @@ -285,7 +289,11 @@ function runTest (withObjectStorage: boolean) { // Subtitles expect(video.subtitleLanguage).to.have.lengthOf(2) 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 @@ -905,7 +913,6 @@ function runTest (withObjectStorage: boolean) { } describe('Test user export', function () { - describe('From filesystem', function () { runTest(false) }) diff --git a/packages/tests/src/api/users/user-import.ts b/packages/tests/src/api/users/user-import.ts index 59014486e..d20e23f6f 100644 --- a/packages/tests/src/api/users/user-import.ts +++ b/packages/tests/src/api/users/user-import.ts @@ -14,12 +14,7 @@ import { VideoState } from '@peertube/peertube-models' import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' -import { - ObjectStorageCommand, - PeerTubeServer, - cleanupTests, - waitJobs -} from '@peertube/peertube-server-commands' +import { ObjectStorageCommand, PeerTubeServer, cleanupTests, waitJobs } from '@peertube/peertube-server-commands' import { testAvatarSize, testImage } from '@tests/shared/checks.js' import { prepareImportExportTests } from '@tests/shared/import-export.js' import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' @@ -57,9 +52,8 @@ function runTest (withObjectStorage: boolean) { objectStorage = withObjectStorage ? new ObjectStorageCommand() - : undefined; - - ({ + : undefined + ;({ noahId, externalVideo, noahVideo, @@ -122,7 +116,6 @@ function runTest (withObjectStorage: boolean) { }) describe('Import process', function () { - it('Should import an archive with video files', async function () { this.timeout(240000) @@ -142,7 +135,6 @@ function runTest (withObjectStorage: boolean) { }) describe('Import data', function () { - it('Should have correctly imported blocklist', async function () { { 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 () { + // 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 }) 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({ hlsOnly: false, - servers: [ remoteServer, server ], + servers: [ remoteServer ], videoUUID: otherVideo.uuid, 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 }) @@ -498,7 +505,6 @@ function runTest (withObjectStorage: boolean) { }) describe('Re-import', function () { - it('Should re-import the same file', async function () { this.timeout(240000) @@ -584,7 +590,6 @@ function runTest (withObjectStorage: boolean) { }) describe('After import', function () { - it('Should have received an email on finished import', async function () { const email = emails.reverse().find(e => { 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 () { - async function generateAndExportImport (username: string) { const archivePath = join(server.getDirectoryPath('tmp'), `archive${username}.zip`) const fixture = 'video_short1.webm' @@ -718,7 +722,6 @@ function runTest (withObjectStorage: boolean) { } describe('Test user import', function () { - describe('From filesystem', function () { runTest(false) }) diff --git a/packages/tests/src/api/videos/video-captions-playlist.ts b/packages/tests/src/api/videos/video-captions-playlist.ts new file mode 100644 index 000000000..bbffe4342 --- /dev/null +++ b/packages/tests/src/api/videos/video-captions-playlist.ts @@ -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) + }) +}) diff --git a/packages/tests/src/api/videos/video-transcription.ts b/packages/tests/src/api/videos/video-transcription.ts index fb330346f..f010f41d2 100644 --- a/packages/tests/src/api/videos/video-transcription.ts +++ b/packages/tests/src/api/videos/video-transcription.ts @@ -1,6 +1,6 @@ /* 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 { ObjectStorageCommand, @@ -15,6 +15,7 @@ import { waitJobs } from '@peertube/peertube-server-commands' 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 { expect } from 'chai' import { join } from 'path' @@ -39,11 +40,12 @@ describe('Test video transcription', function () { // --------------------------------------------------------------------------- describe('Common on filesystem', function () { - it('Should generate a transcription on request', async function () { this.timeout(360000) 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]) await waitJobs(servers) @@ -56,6 +58,22 @@ describe('Test video transcription', function () { await checkLanguage(servers, uuid, 'en') 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 () { @@ -260,6 +278,8 @@ describe('Test video transcription', function () { this.timeout(360000) 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]) await waitJobs(servers) @@ -272,6 +292,23 @@ describe('Test video transcription', function () { await checkLanguage(servers, uuid, 'en') 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 () { diff --git a/packages/tests/src/cli/create-move-video-storage-job.ts b/packages/tests/src/cli/create-move-video-storage-job.ts index 596f61a65..8139c7199 100644 --- a/packages/tests/src/cli/create-move-video-storage-job.ts +++ b/packages/tests/src/cli/create-move-video-storage-job.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 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 { ObjectStorageCommand, @@ -15,6 +15,7 @@ import { waitJobs } from '@peertube/peertube-server-commands' import { checkDirectoryIsEmpty } from '@tests/shared/directories.js' +import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' import { join } from 'path' 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 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 () { - it('Should move only one file', async function () { 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 () { + await checkDirectoryIsEmpty(servers[0], 'captions', [ 'private' ]) + await checkDirectoryIsEmpty(servers[0], '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 () { const video = await servers[0].videos.get({ id: uuids[1] }) + const { data: captions } = await servers[0].captions.list({ videoId: uuids[1] }) oldFileUrls = [ ...getAllFiles(video).map(f => f.fileUrl), + + ...captions.map(c => c.fileUrl), + ...captions.map(c => c.m3u8Url), + video.streamingPlaylists[0].playlistUrl ] }) diff --git a/packages/tests/src/peertube-runner/video-transcription.ts b/packages/tests/src/peertube-runner/video-transcription.ts index aa106c8d1..0017abcf3 100644 --- a/packages/tests/src/peertube-runner/video-transcription.ts +++ b/packages/tests/src/peertube-runner/video-transcription.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 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 { ObjectStorageCommand, @@ -15,6 +15,7 @@ import { } from '@peertube/peertube-server-commands' import { checkPeerTubeRunnerCacheIsEmpty } from '@tests/shared/directories.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 { expect } from 'chai' @@ -42,9 +43,7 @@ describe('Test transcription in peertube-runner program', function () { }) describe('Running transcription', function () { - describe('Common on filesystem', function () { - it('Should run transcription on classic file', async function () { 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 () { this.timeout(360000) + await servers[0].config.save() + await servers[0].config.enableMinimumTranscoding({ webVideo: false, hls: true }) + const uuid = await uploadForTranscription(servers[0]) await waitJobs(servers, { runnerJobs: true, skipFailed: true }) // skipFailed because previous test had a failed runner job await checkAutoCaption({ servers, uuid, objectStorageBaseUrl: objectStorage.getMockCaptionFileBaseUrl() }) 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 () { @@ -121,7 +139,6 @@ describe('Test transcription in peertube-runner program', function () { }) describe('When transcription is not enabled in runner', function () { - before(async function () { await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' }) peertubeRunner.kill() @@ -148,7 +165,6 @@ describe('Test transcription in peertube-runner program', function () { }) describe('Check cleanup', function () { - it('Should have an empty cache directory', async function () { await checkPeerTubeRunnerCacheIsEmpty(peertubeRunner, 'transcription') }) diff --git a/packages/tests/src/shared/import-export.ts b/packages/tests/src/shared/import-export.ts index e24ac80b4..2fd46f6ad 100644 --- a/packages/tests/src/shared/import-export.ts +++ b/packages/tests/src/shared/import-export.ts @@ -19,7 +19,8 @@ import { ObjectStorageCommand, PeerTubeServer, createSingleServer, - doubleFollow, makeRawRequest, + doubleFollow, + makeRawRequest, setAccessTokensToServers, setDefaultVideoChannel, waitJobs @@ -49,7 +50,7 @@ export async function downloadZIP (server: PeerTubeServer, userId: number) { return JSZip.loadAsync(res.body) } -export async function parseZIPJSONFile (zip: JSZip, path: string) { +export async function parseZIPJSONFile (zip: JSZip, path: string) { 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 noahVideo = await server.videos.quickUpload({ name: 'noah public video', token: noahToken, privacy: VideoPrivacy.PUBLIC }) // eslint-disable-next-line max-len - await server.videos.upload({ + const noahVideo2 = await server.videos.upload({ token: noahToken, attributes: { 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: '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 await server.chapters.update({ 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 }) // 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: noahPrivateVideo.uuid } }) diff --git a/packages/tests/src/shared/streaming-playlists.ts b/packages/tests/src/shared/streaming-playlists.ts index ae8651a21..1f2092faf 100644 --- a/packages/tests/src/shared/streaming-playlists.ts +++ b/packages/tests/src/shared/streaming-playlists.ts @@ -4,7 +4,9 @@ import { getHLS, removeFragmentedMP4Ext, uuidRegex } from '@peertube/peertube-co import { FileStorage, HttpStatusCode, + VideoCaption, VideoDetails, + VideoFile, VideoPrivacy, VideoResolution, VideoStreamingPlaylist, @@ -99,6 +101,7 @@ export async function checkResolutionsInMasterPlaylist (options: { server: PeerTubeServer playlistUrl: string resolutions: number[] + framerates?: { [id: number]: number } token?: string transcoded?: boolean // default true @@ -106,6 +109,7 @@ export async function checkResolutionsInMasterPlaylist (options: { splittedAudio?: boolean // default false hasAudio?: boolean // default true hasVideo?: boolean // default true + hasCaptions?: boolean // default false }) { const { server, @@ -117,7 +121,8 @@ export async function checkResolutionsInMasterPlaylist (options: { hasVideo = true, splittedAudio = false, withRetry = false, - transcoded = true + transcoded = true, + hasCaptions = false } = options 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}` } - expect(masterPlaylist).to.match(new RegExp(`${regexp}`)) + if (hasCaptions) { + regexp += `,SUBTITLES="subtitles"` + } + + expect(masterPlaylist, masterPlaylist).to.match(new RegExp(`${regexp}`)) } if (splittedAudio && hasAudio && hasVideo) { expect(masterPlaylist).to.match( // 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) } +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: { servers: PeerTubeServer[] videoUUID: string @@ -185,9 +216,11 @@ export async function completeCheckHlsPlaylist (options: { hasVideo?: boolean // default true resolutions?: number[] + captions?: VideoCaption[] + 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 webVideoResolutions = [ ...hlsResolutions ] @@ -225,73 +258,26 @@ export async function completeCheckHlsPlaylist (options: { // Check JSON files for (const resolution of hlsResolutions) { const file = hlsFiles.find(f => f.resolution.id === resolution) - 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 + await checkHLSResolution({ + file, + resolution, + hasAudio, + splittedAudio, + isOrigin, + objectStorageBaseUrl, + requiresAuth, + server, + videoDetails, + privatePath, + baseUrl, + token + }) + } - 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) { - // 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 @@ -303,14 +289,21 @@ export async function completeCheckHlsPlaylist (options: { resolutions: hlsResolutions, hasAudio, 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 }) for (const resolution of hlsResolutions) { 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) .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) +} diff --git a/server/core/controllers/api/videos/captions.ts b/server/core/controllers/api/videos/captions.ts index 54573d60e..624bdba72 100644 --- a/server/core/controllers/api/videos/captions.ts +++ b/server/core/controllers/api/videos/captions.ts @@ -1,6 +1,7 @@ import { HttpStatusCode, VideoCaptionGenerate } from '@peertube/peertube-models' +import { retryTransactionWrapper } from '@server/helpers/database-utils.js' import { Hooks } from '@server/lib/plugins/hooks.js' -import { createLocalCaption, createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js' +import { createLocalCaption, createTranscriptionTaskIfNeeded, updateHLSMasterOnCaptionChangeIfNeeded } from '@server/lib/video-captions.js' import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' import express from 'express' import { createReqFiles } from '../../../helpers/express-utils.js' @@ -17,7 +18,6 @@ import { listVideoCaptionsValidator } from '../../../middlewares/validators/index.js' import { VideoCaptionModel } from '../../../models/video/video-caption.js' -import { retryTransactionWrapper } from '@server/helpers/database-utils.js' const lTags = loggerTagsFactory('api', 'video-caption') @@ -25,25 +25,25 @@ const reqVideoCaptionAdd = createReqFiles([ 'captionfile' ], MIMETYPES.VIDEO_CAP const videoCaptionsRouter = express.Router() -videoCaptionsRouter.post('/:videoId/captions/generate', +videoCaptionsRouter.post( + '/:videoId/captions/generate', authenticate, asyncMiddleware(generateVideoCaptionValidator), asyncMiddleware(createGenerateVideoCaption) ) -videoCaptionsRouter.get('/:videoId/captions', - asyncMiddleware(listVideoCaptionsValidator), - asyncMiddleware(listVideoCaptions) -) +videoCaptionsRouter.get('/:videoId/captions', asyncMiddleware(listVideoCaptionsValidator), asyncMiddleware(listVideoCaptions)) -videoCaptionsRouter.put('/:videoId/captions/:captionLanguage', +videoCaptionsRouter.put( + '/:videoId/captions/:captionLanguage', authenticate, reqVideoCaptionAdd, asyncMiddleware(addVideoCaptionValidator), asyncMiddleware(createVideoCaption) ) -videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage', +videoCaptionsRouter.delete( + '/:videoId/captions/:captionLanguage', authenticate, asyncMiddleware(deleteVideoCaptionValidator), asyncRetryTransactionMiddleware(deleteVideoCaption) @@ -89,10 +89,12 @@ async function createVideoCaption (req: express.Request, res: express.Response) automaticallyGenerated: false }) + if (videoCaption.m3u8Filename) { + await updateHLSMasterOnCaptionChangeIfNeeded(video) + } + await retryTransactionWrapper(() => { - return sequelizeTypescript.transaction(async t => { - return federateVideoIfNeeded(video, false, t) - }) + return sequelizeTypescript.transaction(t => federateVideoIfNeeded(video, false, t)) }) Hooks.runAction('action:api.video-caption.created', { caption: videoCaption, req, res }) @@ -103,12 +105,18 @@ async function createVideoCaption (req: express.Request, res: express.Response) async function deleteVideoCaption (req: express.Request, res: express.Response) { const video = res.locals.videoAll const videoCaption = res.locals.videoCaption + const hasM3U8 = !!videoCaption.m3u8Filename await sequelizeTypescript.transaction(async t => { await videoCaption.destroy({ transaction: t }) + }) - // Send video update - await federateVideoIfNeeded(video, false, t) + if (hasM3U8) { + await updateHLSMasterOnCaptionChangeIfNeeded(video) + } + + await retryTransactionWrapper(() => { + return sequelizeTypescript.transaction(t => federateVideoIfNeeded(video, false, t)) }) logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid, lTags(video.uuid)) diff --git a/server/core/controllers/api/videos/source.ts b/server/core/controllers/api/videos/source.ts index 3c65357c9..f6eac1bd2 100644 --- a/server/core/controllers/api/videos/source.ts +++ b/server/core/controllers/api/videos/source.ts @@ -29,14 +29,16 @@ const lTags = loggerTagsFactory('api', 'video') const videoSourceRouter = express.Router() -videoSourceRouter.get('/:id/source', +videoSourceRouter.get( + '/:id/source', openapiOperationDoc({ operationId: 'getVideoSource' }), authenticate, asyncMiddleware(videoSourceGetLatestValidator), getVideoLatestSource ) -videoSourceRouter.delete('/:id/source/file', +videoSourceRouter.delete( + '/:id/source/file', openapiOperationDoc({ operationId: 'deleteVideoSourceFile' }), authenticate, ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES), @@ -211,6 +213,6 @@ async function removeOldFiles (options: { } for (const playlist of playlists) { - await video.removeStreamingPlaylistFiles(playlist) + await video.removeAllStreamingPlaylistFiles({ playlist }) } } diff --git a/server/core/controllers/static.ts b/server/core/controllers/static.ts index 6c6894536..6416f2999 100644 --- a/server/core/controllers/static.ts +++ b/server/core/controllers/static.ts @@ -104,5 +104,5 @@ async function servePrivateM3U8 (req: express.Request, res: express.Response) { ? injectQueryToPlaylistUrls(playlistContent, buildReinjectVideoFileTokenQuery(req, filename.endsWith('master.m3u8'))) : playlistContent - return res.set('content-type', 'application/vnd.apple.mpegurl').send(transformedContent).end() + return res.set('content-type', 'application/x-mpegurl; charset=utf-8').send(transformedContent).end() } diff --git a/server/core/helpers/captions-utils.ts b/server/core/helpers/captions-utils.ts index 97110268f..3f3e4dbab 100644 --- a/server/core/helpers/captions-utils.ts +++ b/server/core/helpers/captions-utils.ts @@ -4,8 +4,8 @@ import { Transform } from 'stream' import { MVideoCaption } from '@server/types/models/index.js' import { pipelinePromise } from './core-utils.js' -async function moveAndProcessCaptionFile (physicalFile: { filename?: string, path: string }, videoCaption: MVideoCaption) { - const destination = videoCaption.getFSPath() +export async function moveAndProcessCaptionFile (physicalFile: { filename?: string, path: string }, videoCaption: MVideoCaption) { + const destination = videoCaption.getFSFilePath() // Convert this srt file to vtt if (physicalFile.path.endsWith('.srt')) { @@ -22,20 +22,14 @@ async function moveAndProcessCaptionFile (physicalFile: { filename?: string, pat // --------------------------------------------------------------------------- -export { - moveAndProcessCaptionFile -} - -// --------------------------------------------------------------------------- - async function convertSrtToVtt (source: string, destination: string) { const fixVTT = new Transform({ transform: (chunk, _encoding, cb) => { let block: string = chunk.toString() block = block.replace(/(\d\d:\d\d:\d\d)(\s)/g, '$1.000$2') - .replace(/(\d\d:\d\d:\d\d),(\d)(\s)/g, '$1.00$2$3') - .replace(/(\d\d:\d\d:\d\d),(\d\d)(\s)/g, '$1.0$2$3') + .replace(/(\d\d:\d\d:\d\d),(\d)(\s)/g, '$1.00$2$3') + .replace(/(\d\d:\d\d:\d\d),(\d\d)(\s)/g, '$1.0$2$3') return cb(undefined, block) } diff --git a/server/core/helpers/custom-validators/activitypub/videos.ts b/server/core/helpers/custom-validators/activitypub/videos.ts index 634821917..b95b8cd87 100644 --- a/server/core/helpers/custom-validators/activitypub/videos.ts +++ b/server/core/helpers/custom-validators/activitypub/videos.ts @@ -1,4 +1,6 @@ +import { arrayify } from '@peertube/peertube-core-utils' import { + ActivityCaptionUrlObject, ActivityPubStoryboard, ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject, @@ -96,14 +98,14 @@ export function sanitizeAndCheckVideoTorrentObject (video: VideoObject) { export function isRemoteVideoUrlValid (url: any) { return url.type === 'Link' && - // Video file link - ( - MIMETYPES.AP_VIDEO.MIMETYPE_EXT[url.mediaType] && - isActivityPubUrlValid(url.href) && - validator.default.isInt(url.height + '', { min: 0 }) && - validator.default.isInt(url.size + '', { min: 0 }) && - (!url.fps || validator.default.isInt(url.fps + '', { min: -1 })) - ) || + // Video file link + ( + MIMETYPES.AP_VIDEO.MIMETYPE_EXT[url.mediaType] && + isActivityPubUrlValid(url.href) && + validator.default.isInt(url.height + '', { min: 0 }) && + validator.default.isInt(url.size + '', { min: 0 }) && + (!url.fps || validator.default.isInt(url.fps + '', { min: -1 })) + ) || // Torrent link ( MIMETYPES.AP_TORRENT.MIMETYPE_EXT[url.mediaType] && @@ -139,6 +141,13 @@ export function isAPVideoTrackerUrlObject (url: any): url is ActivityTrackerUrlO isActivityPubUrlValid(url.href) } +export function isAPCaptionUrlObject (url: any): url is ActivityCaptionUrlObject { + return url && + url.type === 'Link' && + (url.mediaType === 'text/vtt' || url.mediaType === 'application/x-mpegURL') && + isActivityPubUrlValid(url.href) +} + // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- @@ -157,7 +166,21 @@ function setValidRemoteCaptions (video: VideoObject) { if (Array.isArray(video.subtitleLanguage) === false) return false video.subtitleLanguage = video.subtitleLanguage.filter(caption => { - if (!isActivityPubUrlValid(caption.url)) caption.url = null + if (typeof caption.url === 'string') { + if (isActivityPubUrlValid(caption.url)) { + caption.url = [ + { + type: 'Link', + href: caption.url, + mediaType: 'text/vtt' + } + ] + } else { + caption.url = [] + } + } else { + caption.url = arrayify(caption.url).filter(u => isAPCaptionUrlObject(u)) + } return isRemoteStringIdentifierValid(caption) }) diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index 4d88f6b51..6679a04e1 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js' // --------------------------------------------------------------------------- -export const LAST_MIGRATION_VERSION = 885 +export const LAST_MIGRATION_VERSION = 890 // --------------------------------------------------------------------------- diff --git a/server/core/initializers/migrations/0890-hls-caption.ts b/server/core/initializers/migrations/0890-hls-caption.ts new file mode 100644 index 000000000..7ee028a9e --- /dev/null +++ b/server/core/initializers/migrations/0890-hls-caption.ts @@ -0,0 +1,34 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + 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 +} diff --git a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts index 1e23216d0..5920ad4ec 100644 --- a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts +++ b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts @@ -1,5 +1,6 @@ import { arrayify, maxBy, minBy } from '@peertube/peertube-core-utils' import { + ActivityCaptionUrlObject, ActivityHashTagObject, ActivityMagnetUrlObject, ActivityPlaylistSegmentHashesObject, @@ -65,11 +66,11 @@ export function getFileAttributesFromUrl ( for (const fileUrl of fileUrls) { // Fetch associated metadata url, if any const metadata = urls.filter(isAPVideoFileUrlMetadataObject) - .find(u => { - return u.height === fileUrl.height && - u.fps === fileUrl.fps && - u.rel.includes(fileUrl.mediaType) - }) + .find(u => { + return u.height === fileUrl.height && + u.fps === fileUrl.fps && + u.rel.includes(fileUrl.mediaType) + }) const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType) const resolution = fileUrl.height @@ -204,13 +205,23 @@ export function getLiveAttributesFromObject (video: MVideoId, videoObject: Video } export function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) { - return videoObject.subtitleLanguage.map(c => ({ - videoId: video.id, - filename: VideoCaptionModel.generateCaptionName(c.identifier), - language: c.identifier, - automaticallyGenerated: c.automaticallyGenerated === true, - fileUrl: c.url - })) + return videoObject.subtitleLanguage.map(c => { + // This field is sanitized in validators + // TODO: Remove as in v8 + const url = c.url as (ActivityCaptionUrlObject | ActivityPlaylistUrlObject)[] + + const filename = VideoCaptionModel.generateCaptionName(c.identifier) + + return { + videoId: video.id, + filename, + language: c.identifier, + automaticallyGenerated: c.automaticallyGenerated === true, + fileUrl: url.find(u => u.mediaType === 'text/vtt')?.href, + m3u8Filename: VideoCaptionModel.generateM3U8Filename(filename), + m3u8Url: url.find(u => u.mediaType === 'application/x-mpegURL')?.href + } as Partial> + }) } export function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) { diff --git a/server/core/lib/files-cache/video-captions-simple-file-cache.ts b/server/core/lib/files-cache/video-captions-simple-file-cache.ts index 7cef1d966..9f68dc511 100644 --- a/server/core/lib/files-cache/video-captions-simple-file-cache.ts +++ b/server/core/lib/files-cache/video-captions-simple-file-cache.ts @@ -6,8 +6,7 @@ import { VideoModel } from '../../models/video/video.js' import { VideoCaptionModel } from '../../models/video/video-caption.js' import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js' -class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache { - +class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache { private static instance: VideoCaptionsSimpleFileCache private constructor () { @@ -23,7 +22,7 @@ class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache { if (!videoCaption) return undefined if (videoCaption.isOwned()) { - return { isOwned: true, path: videoCaption.getFSPath() } + return { isOwned: true, path: videoCaption.getFSFilePath() } } return this.loadRemoteFile(filename) diff --git a/server/core/lib/hls.ts b/server/core/lib/hls.ts index 7e3cb8b26..6b473c488 100644 --- a/server/core/lib/hls.ts +++ b/server/core/lib/hls.ts @@ -2,7 +2,8 @@ import { sortBy, uniqify, uuidRegex } from '@peertube/peertube-core-utils' import { ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg' import { FileStorage, VideoResolution } from '@peertube/peertube-models' import { sha256 } from '@peertube/peertube-node-utils' -import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js' +import { VideoCaptionModel } from '@server/models/video/video-caption.js' +import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo, MVideoCaption } from '@server/types/models/index.js' import { ensureDir, move, outputJSON, remove } from 'fs-extra/esm' import { open, readFile, stat, writeFile } from 'fs/promises' import flatten from 'lodash-es/flatten.js' @@ -18,7 +19,7 @@ import { sequelizeTypescript } from '../initializers/database.js' import { VideoFileModel } from '../models/video/video-file.js' import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist.js' import { storeHLSFileFromContent } from './object-storage/index.js' -import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHlsResolutionPlaylistFilename } from './paths.js' +import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHLSResolutionPlaylistFilename } from './paths.js' import { VideoPathManager } from './video-path-manager.js' const lTags = loggerTagsFactory('hls') @@ -65,19 +66,31 @@ export async function updateM3U8AndShaPlaylist (video: MVideo, playlist: MStream // Avoid concurrency issues when updating streaming playlist files const playlistFilesQueue = new PQueue({ concurrency: 1 }) -export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise { +function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise { return playlistFilesQueue.add(async () => { const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id) + const captions = await VideoCaptionModel.listVideoCaptions(video.id) - const extMedia: string[] = [] + const extMediaAudio: string[] = [] + const extMediaSubtitle: string[] = [] const extStreamInfo: string[] = [] let separatedAudioCodec: string const splitAudioAndVideo = playlist.hasAudioAndVideoSplitted() + for (const caption of captions) { + if (!caption.m3u8Filename) continue + + extMediaSubtitle.push( + `#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subtitles",` + + `NAME="${VideoCaptionModel.getLanguageLabel(caption.language)}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,` + + `LANGUAGE="${caption.language}",URI="${caption.m3u8Filename}"` + ) + } + // Sort to have the audio resolution first (if it exists) for (const file of sortBy(playlist.VideoFiles, 'resolution')) { - const playlistFilename = getHlsResolutionPlaylistFilename(file.filename) + const playlistFilename = getHLSResolutionPlaylistFilename(file.filename) await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => { const probe = await ffprobePromise(videoFilePath) @@ -103,9 +116,8 @@ export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingP line += `,CODECS="${codecs.filter(c => !!c).join(',')}"` - if (splitAudioAndVideo) { - line += `,AUDIO="audio"` - } + if (splitAudioAndVideo) line += `,AUDIO="audio"` + if (extMediaSubtitle.length !== 0) line += `,SUBTITLES="subtitles"` // Don't include audio only resolution as a regular "video" resolution // Some player may use it automatically and so the user would not have a video stream @@ -114,12 +126,12 @@ export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingP extStreamInfo.push(line) extStreamInfo.push(playlistFilename) } else if (splitAudioAndVideo) { - extMedia.push(`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Audio",AUTOSELECT=YES,DEFAULT=YES,URI="${playlistFilename}"`) + extMediaAudio.push(`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Audio",AUTOSELECT=YES,DEFAULT=YES,URI="${playlistFilename}"`) } }) } - const masterPlaylists = [ '#EXTM3U', '#EXT-X-VERSION:3', '', ...extMedia, '', ...extStreamInfo ] + const masterPlaylists = [ '#EXTM3U', '#EXT-X-VERSION:3', '', ...extMediaSubtitle, '', ...extMediaAudio, '', ...extStreamInfo ] if (playlist.playlistFilename) { await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename) @@ -129,7 +141,12 @@ export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingP const masterPlaylistContent = masterPlaylists.join('\n') + '\n' if (playlist.storage === FileStorage.OBJECT_STORAGE) { - playlist.playlistUrl = await storeHLSFileFromContent(playlist, playlist.playlistFilename, masterPlaylistContent) + playlist.playlistUrl = await storeHLSFileFromContent({ + playlist, + pathOrFilename: playlist.playlistFilename, + content: masterPlaylistContent, + contentType: 'application/x-mpegurl; charset=utf-8' + }) logger.info(`Updated master playlist file of video ${video.uuid} to object storage ${playlist.playlistUrl}`, lTags(video.uuid)) } else { @@ -145,7 +162,7 @@ export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingP // --------------------------------------------------------------------------- -export function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise { +function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise { return playlistFilesQueue.add(async () => { const json: { [filename: string]: { [range: string]: string } } = {} @@ -157,7 +174,6 @@ export function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingP const fileWithPlaylist = file.withVideoOrPlaylist(playlist) await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => { - return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => { const playlistContent = await readFile(resolutionPlaylistPath) const ranges = getRangesFromPlaylist(playlistContent.toString()) @@ -183,7 +199,12 @@ export function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingP playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive) if (playlist.storage === FileStorage.OBJECT_STORAGE) { - playlist.segmentsSha256Url = await storeHLSFileFromContent(playlist, playlist.segmentsSha256Filename, JSON.stringify(json)) + playlist.segmentsSha256Url = await storeHLSFileFromContent({ + playlist, + pathOrFilename: playlist.segmentsSha256Filename, + content: JSON.stringify(json), + contentType: 'application/json; charset=utf-8' + }) } else { const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename) await outputJSON(outputPath, json) @@ -232,7 +253,7 @@ export function downloadPlaylistSegments (playlistUrl: string, destinationDir: s await doRequestAndSaveToFile(fileUrl, destPath, { bodyKBLimit: remainingBodyKBLimit, timeout: REQUEST_TIMEOUTS.REDUNDANCY }) const { size } = await stat(destPath) - remainingBodyKBLimit -= (size / 1000) + remainingBodyKBLimit -= size / 1000 logger.debug('Downloaded HLS playlist file %s with %d kB remained limit.', fileUrl, Math.floor(remainingBodyKBLimit)) } @@ -287,6 +308,18 @@ export function injectQueryToPlaylistUrls (content: string, queryString: string) return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString) } +// --------------------------------------------------------------------------- + +export function buildCaptionM3U8Content (options: { + video: MVideo + caption: MVideoCaption +}) { + const { video, caption } = options + + return `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${video.duration}\n#EXT-X-MEDIA-SEQUENCE:0\n` + + `#EXTINF:${video.duration},\n${caption.getFileUrl(video)}\n#EXT-X-ENDLIST\n` +} + // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- diff --git a/server/core/lib/job-queue/handlers/move-to-file-system.ts b/server/core/lib/job-queue/handlers/move-to-file-system.ts index 3b0b5ed43..f5d07b0a2 100644 --- a/server/core/lib/job-queue/handlers/move-to-file-system.ts +++ b/server/core/lib/job-queue/handlers/move-to-file-system.ts @@ -13,7 +13,8 @@ import { removeOriginalFileObjectStorage, removeWebVideoObjectStorage } from '@server/lib/object-storage/index.js' -import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths.js' +import { getHLSDirectory, getHLSResolutionPlaylistFilename } from '@server/lib/paths.js' +import { updateHLSMasterOnCaptionChange, upsertCaptionPlaylistOnFS } from '@server/lib/video-captions.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' import { moveToFailedMoveToFileSystemState, moveToNextState } from '@server/lib/video-state.js' import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.js' @@ -115,7 +116,7 @@ async function moveHLSFiles (video: MVideoWithAllFiles) { if (file.storage === FileStorage.FILE_SYSTEM) continue // Resolution playlist - const playlistFilename = getHlsResolutionPlaylistFilename(file.filename) + const playlistFilename = getHLSResolutionPlaylistFilename(file.filename) await makeHLSFileAvailable(playlistWithVideo, playlistFilename, join(getHLSDirectory(video), playlistFilename)) await makeHLSFileAvailable(playlistWithVideo, file.filename, join(getHLSDirectory(video), file.filename)) @@ -152,21 +153,47 @@ async function onVideoFileMoved (options: { // --------------------------------------------------------------------------- -async function moveCaptionFiles (captions: MVideoCaption[]) { +async function moveCaptionFiles (captions: MVideoCaption[], hls: MStreamingPlaylistVideo) { + let hlsUpdated = false + for (const caption of captions) { - if (caption.storage === FileStorage.FILE_SYSTEM) continue + if (caption.storage === FileStorage.OBJECT_STORAGE) { + const oldFileUrl = caption.fileUrl - await makeCaptionFileAvailable(caption.filename, caption.getFSPath()) + await makeCaptionFileAvailable(caption.filename, caption.getFSFilePath()) - const oldFileUrl = caption.fileUrl + // Assign new values before building the m3u8 file + caption.fileUrl = null + caption.storage = FileStorage.FILE_SYSTEM - caption.fileUrl = null - caption.storage = FileStorage.FILE_SYSTEM - await caption.save() + await caption.save() - logger.debug('Removing caption file %s because it\'s now on file system', oldFileUrl, lTagsBase()) + logger.debug('Removing caption file %s because it\'s now on file system', oldFileUrl, lTagsBase()) + await removeCaptionObjectStorage(caption) + } - await removeCaptionObjectStorage(caption) + if (hls && (!caption.m3u8Filename || caption.m3u8Url)) { + hlsUpdated = true + + const oldM3U8Url = caption.m3u8Url + const oldM3U8Filename = caption.m3u8Filename + + // Caption link has been updated, so we must also update the HLS caption playlist + caption.m3u8Filename = await upsertCaptionPlaylistOnFS(caption, hls.Video) + caption.m3u8Url = null + + await caption.save() + + if (oldM3U8Url) { + logger.debug(`Removing video caption playlist file ${oldM3U8Url} because it's now on file system`, lTagsBase()) + + await removeHLSFileObjectStorageByFilename(hls, oldM3U8Filename) + } + } + } + + if (hlsUpdated) { + await updateHLSMasterOnCaptionChange(hls.Video, hls) } } diff --git a/server/core/lib/job-queue/handlers/move-to-object-storage.ts b/server/core/lib/job-queue/handlers/move-to-object-storage.ts index f9054e375..b66716808 100644 --- a/server/core/lib/job-queue/handlers/move-to-object-storage.ts +++ b/server/core/lib/job-queue/handlers/move-to-object-storage.ts @@ -2,8 +2,16 @@ import { FileStorage, isMoveCaptionPayload, isMoveVideoStoragePayload, MoveStora import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { updateTorrentMetadata } from '@server/helpers/webtorrent.js' import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants.js' -import { storeHLSFileFromFilename, storeOriginalVideoFile, storeVideoCaption, storeWebVideoFile } from '@server/lib/object-storage/index.js' -import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths.js' +import { buildCaptionM3U8Content } from '@server/lib/hls.js' +import { + storeHLSFileFromContent, + storeHLSFileFromFilename, + storeOriginalVideoFile, + storeVideoCaption, + storeWebVideoFile +} from '@server/lib/object-storage/index.js' +import { getHLSDirectory, getHLSResolutionPlaylistFilename } from '@server/lib/paths.js' +import { updateHLSMasterOnCaptionChange } from '@server/lib/video-captions.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' import { moveToFailedMoveToObjectStorageState, moveToNextState } from '@server/lib/video-state.js' import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.js' @@ -13,6 +21,7 @@ import { remove } from 'fs-extra/esm' import { join } from 'path' import { moveCaptionToStorageJob } from './shared/move-caption.js' import { moveVideoToStorageJob, onMoveVideoToStorageFailure } from './shared/move-video.js' +import { VideoCaptionModel } from '@server/models/video/video-caption.js' const lTagsBase = loggerTagsFactory('move-object-storage') @@ -84,20 +93,50 @@ async function moveVideoSourceFile (source: MVideoSource) { // --------------------------------------------------------------------------- -async function moveCaptionFiles (captions: MVideoCaption[]) { +async function moveCaptionFiles (captions: MVideoCaption[], hls: MStreamingPlaylistVideo) { + let hlsUpdated = false + for (const caption of captions) { - if (caption.storage !== FileStorage.FILE_SYSTEM) continue + if (caption.storage === FileStorage.FILE_SYSTEM) { + const captionPath = caption.getFSFilePath() - const captionPath = caption.getFSPath() - const fileUrl = await storeVideoCaption(captionPath, caption.filename) + // Assign new values before building the m3u8 file + caption.fileUrl = await storeVideoCaption(captionPath, caption.filename) + caption.storage = FileStorage.OBJECT_STORAGE - caption.storage = FileStorage.OBJECT_STORAGE - caption.fileUrl = fileUrl - await caption.save() + await caption.save() - logger.debug(`Removing video caption file ${captionPath} because it's now on object storage`, lTagsBase()) + logger.debug(`Removing video caption file ${captionPath} because it's now on object storage`, lTagsBase()) + await remove(captionPath) + } - await remove(captionPath) + if (hls && (!caption.m3u8Filename || !caption.m3u8Url)) { + hlsUpdated = true + + const m3u8PathToRemove = caption.getFSM3U8Path(hls.Video) + + // Caption link has been updated, so we must also update the HLS caption playlist + const content = buildCaptionM3U8Content({ video: hls.Video, caption }) + + caption.m3u8Filename = VideoCaptionModel.generateM3U8Filename(caption.filename) + caption.m3u8Url = await storeHLSFileFromContent({ + playlist: hls, + pathOrFilename: caption.m3u8Filename, + content, + contentType: 'application/vnd.apple.mpegurl; charset=utf-8' + }) + + await caption.save() + + if (m3u8PathToRemove) { + logger.debug(`Removing video caption playlist file ${m3u8PathToRemove} because it's now on object storage`, lTagsBase()) + await remove(m3u8PathToRemove) + } + } + } + + if (hlsUpdated) { + await updateHLSMasterOnCaptionChange(hls.Video, hls) } } @@ -122,7 +161,7 @@ async function moveHLSFiles (video: MVideoWithAllFiles) { if (file.storage !== FileStorage.FILE_SYSTEM) continue // Resolution playlist - const playlistFilename = getHlsResolutionPlaylistFilename(file.filename) + const playlistFilename = getHLSResolutionPlaylistFilename(file.filename) await storeHLSFileFromFilename(playlistWithVideo, playlistFilename) // Resolution fragmented file diff --git a/server/core/lib/job-queue/handlers/shared/move-caption.ts b/server/core/lib/job-queue/handlers/shared/move-caption.ts index 260a23f83..bf03bb758 100644 --- a/server/core/lib/job-queue/handlers/shared/move-caption.ts +++ b/server/core/lib/job-queue/handlers/shared/move-caption.ts @@ -4,15 +4,16 @@ import { sequelizeTypescript } from '@server/initializers/database.js' import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' import { VideoCaptionModel } from '@server/models/video/video-caption.js' +import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js' import { VideoModel } from '@server/models/video/video.js' -import { MVideoCaption } from '@server/types/models/index.js' +import { MStreamingPlaylistVideoUUID, MVideoCaption } from '@server/types/models/index.js' export async function moveCaptionToStorageJob (options: { jobId: string captionId: number loggerTags: (number | string)[] - moveCaptionFiles: (captions: MVideoCaption[]) => Promise + moveCaptionFiles: (captions: MVideoCaption[], hls: MStreamingPlaylistVideoUUID) => Promise }) { const { jobId, @@ -32,8 +33,10 @@ export async function moveCaptionToStorageJob (options: { const fileMutexReleaser = await VideoPathManager.Instance.lockFiles(caption.Video.uuid) + const hls = await VideoStreamingPlaylistModel.loadHLSByVideoWithVideo(caption.videoId) + try { - await moveCaptionFiles([ caption ]) + await moveCaptionFiles([ caption ], hls) await retryTransactionWrapper(() => { return sequelizeTypescript.transaction(async t => { diff --git a/server/core/lib/job-queue/handlers/shared/move-video.ts b/server/core/lib/job-queue/handlers/shared/move-video.ts index 785e6f2c3..01a1d8162 100644 --- a/server/core/lib/job-queue/handlers/shared/move-video.ts +++ b/server/core/lib/job-queue/handlers/shared/move-video.ts @@ -4,7 +4,7 @@ import { VideoCaptionModel } from '@server/models/video/video-caption.js' import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' import { VideoSourceModel } from '@server/models/video/video-source.js' import { VideoModel } from '@server/models/video/video.js' -import { MVideoCaption, MVideoWithAllFiles } from '@server/types/models/index.js' +import { MStreamingPlaylistVideoUUID, MVideoCaption, MVideoWithAllFiles } from '@server/types/models/index.js' import { MVideoSource } from '@server/types/models/video/video-source.js' export async function moveVideoToStorageJob (options: { @@ -15,7 +15,7 @@ export async function moveVideoToStorageJob (options: { moveWebVideoFiles: (video: MVideoWithAllFiles) => Promise moveHLSFiles: (video: MVideoWithAllFiles) => Promise moveVideoSourceFile: (source: MVideoSource) => Promise - moveCaptionFiles: (captions: MVideoCaption[]) => Promise + moveCaptionFiles: (captions: MVideoCaption[], hls: MStreamingPlaylistVideoUUID) => Promise moveToFailedState: (video: MVideoWithAllFiles) => Promise doAfterLastMove: (video: MVideoWithAllFiles) => Promise @@ -68,9 +68,10 @@ export async function moveVideoToStorageJob (options: { const captions = await VideoCaptionModel.listVideoCaptions(video.id) if (captions.length !== 0) { - logger.debug(`Moving captions of ${video.uuid}.`, lTags) + logger.debug(`Moving ${captions.length} captions of ${video.uuid}.`, lTags) - await moveCaptionFiles(captions) + const hls = video.getHLSPlaylist() + await moveCaptionFiles(captions, hls) } const pendingMove = await VideoJobInfoModel.decrease(video.uuid, 'pendingMove') diff --git a/server/core/lib/live/live-manager.ts b/server/core/lib/live/live-manager.ts index 4b0c79e59..4661889cf 100644 --- a/server/core/lib/live/live-manager.ts +++ b/server/core/lib/live/live-manager.ts @@ -56,7 +56,6 @@ const config = { const lTags = loggerTagsFactory('live') class LiveManager { - private static instance: LiveManager private readonly muxingSessions = new Map() @@ -274,13 +273,16 @@ class LiveManager { if (this.videoSessions.has(video.uuid)) { logger.warn( 'Video %s has already a live session %s. Refusing stream %s.', - video.uuid, this.videoSessions.get(video.uuid), streamKey, lTags(sessionId, video.uuid) + video.uuid, + this.videoSessions.get(video.uuid), + streamKey, + lTags(sessionId, video.uuid) ) return this.abortSession(sessionId) } // Cleanup old potential live (could happen with a permanent live) - const oldStreamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id) + const oldStreamingPlaylist = await VideoStreamingPlaylistModel.loadHLSByVideo(video.id) if (oldStreamingPlaylist) { if (!videoLive.permanentLive) throw new Error('Found previous session in a non permanent live: ' + video.uuid) @@ -316,7 +318,9 @@ class LiveManager { if (!hasAudio && !hasVideo) { logger.warn( 'Not audio and video streams were found for video %s. Refusing stream %s.', - video.uuid, streamKey, lTags(sessionId, video.uuid) + video.uuid, + streamKey, + lTags(sessionId, video.uuid) ) this.videoSessions.delete(video.uuid) @@ -325,7 +329,12 @@ class LiveManager { logger.info( '%s probing took %d ms (bitrate: %d, fps: %d, resolution: %d)', - inputLocalUrl, Date.now() - now, bitrate, fps, resolution, lTags(sessionId, video.uuid) + inputLocalUrl, + Date.now() - now, + bitrate, + fps, + resolution, + lTags(sessionId, video.uuid) ) const allResolutions = await Hooks.wrapObject( @@ -337,7 +346,9 @@ class LiveManager { if (!hasAudio && allResolutions.length === 1 && allResolutions[0] === VideoResolution.H_NOVIDEO) { logger.warn( 'Cannot stream live to audio only because no video stream is available for video %s. Refusing stream %s.', - video.uuid, streamKey, lTags(sessionId, video.uuid) + video.uuid, + streamKey, + lTags(sessionId, video.uuid) ) this.videoSessions.delete(video.uuid) @@ -345,7 +356,8 @@ class LiveManager { } logger.info( - 'Handling live video of original resolution %d.', resolution, + 'Handling live video of original resolution %d.', + resolution, { allResolutions, ...lTags(sessionId, video.uuid) } ) @@ -426,7 +438,8 @@ class LiveManager { muxingSession.on('bad-socket-health', ({ videoUUID }) => { logger.error( 'Too much data in client socket stream (ffmpeg is too slow to transcode the video).' + - ' Stopping session of video %s.', videoUUID, + ' Stopping session of video %s.', + videoUUID, localLTags ) diff --git a/server/core/lib/live/shared/muxing-session.ts b/server/core/lib/live/shared/muxing-session.ts index faaf96112..fddc060b4 100644 --- a/server/core/lib/live/shared/muxing-session.ts +++ b/server/core/lib/live/shared/muxing-session.ts @@ -51,16 +51,17 @@ interface MuxingSessionEvents { declare interface MuxingSession { on( - event: U, listener: MuxingSessionEvents[U] + event: U, + listener: MuxingSessionEvents[U] ): this emit( - event: U, ...args: Parameters + event: U, + ...args: Parameters ): boolean } class MuxingSession extends EventEmitter { - private transcodingWrapper: AbstractTranscodingWrapper private readonly context: any @@ -222,7 +223,14 @@ class MuxingSession extends EventEmitter { logger.debug('Uploading live master playlist on object storage for %s', this.videoUUID, { masterContent, ...this.lTags() }) - const url = await storeHLSFileFromContent(this.streamingPlaylist, this.streamingPlaylist.playlistFilename, masterContent) + const url = await storeHLSFileFromContent( + { + playlist: this.streamingPlaylist, + pathOrFilename: this.streamingPlaylist.playlistFilename, + content: masterContent, + contentType: 'application/x-mpegurl; charset=utf-8' + } + ) this.streamingPlaylist.playlistUrl = url } @@ -405,18 +413,25 @@ class MuxingSession extends EventEmitter { } const queue = this.objectStorageSendQueues.get(m3u8Path) - await queue.add(() => storeHLSFileFromContent(this.streamingPlaylist, m3u8Path, filteredPlaylistContent)) + await queue.add(() => + storeHLSFileFromContent({ + playlist: this.streamingPlaylist, + pathOrFilename: m3u8Path, + content: filteredPlaylistContent, + contentType: 'application/x-mpegurl; charset=utf-8' + }) + ) } catch (err) { logger.error('Cannot store in object storage m3u8 file %s', m3u8Path, { err, ...this.lTags() }) } } private onTranscodingError () { - this.emit('transcoding-error', ({ videoUUID: this.videoUUID })) + this.emit('transcoding-error', { videoUUID: this.videoUUID }) } private onTranscodedEnded () { - this.emit('transcoding-end', ({ videoUUID: this.videoUUID })) + this.emit('transcoding-end', { videoUUID: this.videoUUID }) logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', this.inputLocalUrl, this.lTags()) @@ -433,7 +448,8 @@ class MuxingSession extends EventEmitter { }) .catch(err => { logger.error( - 'Cannot close watchers of %s or process remaining hash segments.', this.outDirectory, + 'Cannot close watchers of %s or process remaining hash segments.', + this.outDirectory, { err, ...this.lTags() } ) }) @@ -482,7 +498,7 @@ class MuxingSession extends EventEmitter { } private async createLivePlaylist (): Promise { - const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(this.videoLive.Video) + const { playlist } = await VideoStreamingPlaylistModel.loadOrGenerate(this.videoLive.Video) playlist.playlistFilename = generateHLSMasterPlaylistFilename(true) playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(true) diff --git a/server/core/lib/object-storage/keys.ts b/server/core/lib/object-storage/keys.ts index 1c1e92d74..343bc5e41 100644 --- a/server/core/lib/object-storage/keys.ts +++ b/server/core/lib/object-storage/keys.ts @@ -1,11 +1,11 @@ +import { MStreamingPlaylistVideoUUID } from '@server/types/models/index.js' import { join } from 'path' -import { MStreamingPlaylistVideo } from '@server/types/models/index.js' -export function generateHLSObjectStorageKey (playlist: MStreamingPlaylistVideo, filename: string) { +export function generateHLSObjectStorageKey (playlist: MStreamingPlaylistVideoUUID, filename: string) { return join(generateHLSObjectBaseStorageKey(playlist), filename) } -export function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideo) { +export function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideoUUID) { return join(playlist.getStringType(), playlist.Video.uuid) } diff --git a/server/core/lib/object-storage/videos.ts b/server/core/lib/object-storage/videos.ts index b0ceca3af..821cc4bd6 100644 --- a/server/core/lib/object-storage/videos.ts +++ b/server/core/lib/object-storage/videos.ts @@ -1,6 +1,6 @@ import { logger } from '@server/helpers/logger.js' import { CONFIG } from '@server/initializers/config.js' -import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile } from '@server/types/models/index.js' +import { MStreamingPlaylistVideo, MStreamingPlaylistVideoUUID, MVideo, MVideoCaption, MVideoFile } from '@server/types/models/index.js' import { MVideoSource } from '@server/types/models/video/video-source.js' import { basename, join } from 'path' import { getHLSDirectory } from '../paths.js' @@ -50,12 +50,22 @@ export function storeHLSFileFromPath (playlist: MStreamingPlaylistVideo, path: s }) } -export function storeHLSFileFromContent (playlist: MStreamingPlaylistVideo, pathOrFilename: string, content: string) { +export function storeHLSFileFromContent ( + options: { + playlist: MStreamingPlaylistVideo + pathOrFilename: string + content: string + contentType: string + } +) { + const { playlist, pathOrFilename, content, contentType } = options + return storeContent({ content, objectStorageKey: generateHLSObjectStorageKey(playlist, basename(pathOrFilename)), bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS, - isPrivate: playlist.Video.hasPrivateStaticPath() + isPrivate: playlist.Video.hasPrivateStaticPath(), + contentType }) } @@ -113,15 +123,15 @@ export async function updateHLSFilesACL (playlist: MStreamingPlaylistVideo) { // --------------------------------------------------------------------------- -export function removeHLSObjectStorage (playlist: MStreamingPlaylistVideo) { +export function removeHLSObjectStorage (playlist: MStreamingPlaylistVideoUUID) { return removePrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) } -export function removeHLSFileObjectStorageByFilename (playlist: MStreamingPlaylistVideo, filename: string) { +export function removeHLSFileObjectStorageByFilename (playlist: MStreamingPlaylistVideoUUID, filename: string) { return removeObject(generateHLSObjectStorageKey(playlist, filename), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) } -export function removeHLSFileObjectStorageByPath (playlist: MStreamingPlaylistVideo, path: string) { +export function removeHLSFileObjectStorageByPath (playlist: MStreamingPlaylistVideoUUID, path: string) { return removeObject(generateHLSObjectStorageKey(playlist, basename(path)), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) } @@ -149,7 +159,7 @@ export function removeCaptionObjectStorage (videoCaption: MVideoCaption) { // --------------------------------------------------------------------------- -export async function makeHLSFileAvailable (playlist: MStreamingPlaylistVideo, filename: string, destination: string) { +export async function makeHLSFileAvailable (playlist: MStreamingPlaylistVideoUUID, filename: string, destination: string) { const key = generateHLSObjectStorageKey(playlist, filename) logger.info('Fetching HLS file %s from object storage to %s.', key, destination, lTags()) diff --git a/server/core/lib/paths.ts b/server/core/lib/paths.ts index b9909d4ff..9e8c87103 100644 --- a/server/core/lib/paths.ts +++ b/server/core/lib/paths.ts @@ -1,4 +1,5 @@ -import { join } from 'path' +import { removeFragmentedMP4Ext } from '@peertube/peertube-core-utils' +import { buildUUID } from '@peertube/peertube-node-utils' import { CONFIG } from '@server/initializers/config.js' import { DIRECTORIES, VIDEO_LIVE } from '@server/initializers/constants.js' import { @@ -8,10 +9,10 @@ import { MUserImport, MVideo, MVideoFile, + MVideoPrivacy, MVideoUUID } from '@server/types/models/index.js' -import { removeFragmentedMP4Ext } from '@peertube/peertube-core-utils' -import { buildUUID } from '@peertube/peertube-node-utils' +import { join } from 'path' import { isVideoInPrivateDirectory } from './video-privacy.js' // ################## Video file name ################## @@ -34,7 +35,7 @@ export function getLiveReplayBaseDirectory (video: MVideo) { return join(getLiveDirectory(video), VIDEO_LIVE.REPLAY_DIRECTORY) } -export function getHLSDirectory (video: MVideo) { +export function getHLSDirectory (video: MVideoPrivacy) { if (isVideoInPrivateDirectory(video.privacy)) { return join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, video.uuid) } @@ -46,8 +47,7 @@ export function getHLSRedundancyDirectory (video: MVideoUUID) { return join(DIRECTORIES.HLS_REDUNDANCY, video.uuid) } -export function getHlsResolutionPlaylistFilename (videoFilename: string) { - // Video file name already contain resolution +export function getHLSResolutionPlaylistFilename (videoFilename: string) { return removeFragmentedMP4Ext(videoFilename) + '.m3u8' } diff --git a/server/core/lib/transcoding/hls-transcoding.ts b/server/core/lib/transcoding/hls-transcoding.ts index 81dcb9a15..01117877c 100644 --- a/server/core/lib/transcoding/hls-transcoding.ts +++ b/server/core/lib/transcoding/hls-transcoding.ts @@ -12,10 +12,11 @@ import { CONFIG } from '../../initializers/config.js' import { VideoFileModel } from '../../models/video/video-file.js' import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist.js' import { renameVideoFileInPlaylist, updateM3U8AndShaPlaylist } from '../hls.js' -import { generateHLSVideoFilename, getHlsResolutionPlaylistFilename } from '../paths.js' +import { generateHLSVideoFilename, getHLSResolutionPlaylistFilename } from '../paths.js' import { buildNewFile } from '../video-file.js' import { VideoPathManager } from '../video-path-manager.js' import { buildFFmpegVOD } from './shared/index.js' +import { createAllCaptionPlaylistsOnFSIfNeeded } from '../video-captions.js' // Concat TS segments from a live video to a fragmented mp4 HLS playlist export async function generateHlsPlaylistResolutionFromTS (options: { @@ -75,7 +76,7 @@ export async function onHLSVideoFileTranscoding (options: { const { video, videoOutputPath, m3u8OutputPath, filesLockedInParent = false } = options // Create or update the playlist - const playlist = await retryTransactionWrapper(() => { + const { playlist, generated: playlistGenerated } = await retryTransactionWrapper(() => { return sequelizeTypescript.transaction(async transaction => { return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction) }) @@ -97,7 +98,7 @@ export async function onHLSVideoFileTranscoding (options: { // Move playlist file const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath( video, - getHlsResolutionPlaylistFilename(newVideoFile.filename) + getHLSResolutionPlaylistFilename(newVideoFile.filename) ) await move(m3u8OutputPath, resolutionPlaylistPath, { overwrite: true }) @@ -127,6 +128,10 @@ export async function onHLSVideoFileTranscoding (options: { const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined) + if (playlistGenerated) { + await createAllCaptionPlaylistsOnFSIfNeeded(video) + } + await updateM3U8AndShaPlaylist(video, playlist) return { resolutionPlaylistPath, videoFile: savedVideoFile } @@ -180,7 +185,7 @@ async function generateHlsPlaylistCommon (options: { const videoFilename = generateHLSVideoFilename(resolution) const videoOutputPath = join(videoTranscodedBasePath, videoFilename) - const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename) + const resolutionPlaylistFilename = getHLSResolutionPlaylistFilename(videoFilename) const m3u8OutputPath = join(videoTranscodedBasePath, resolutionPlaylistFilename) const transcodeOptions: HLSTranscodeOptions | HLSFromTSTranscodeOptions = { diff --git a/server/core/lib/user-import-export/exporters/videos-exporter.ts b/server/core/lib/user-import-export/exporters/videos-exporter.ts index 966409b44..ee093b3cc 100644 --- a/server/core/lib/user-import-export/exporters/videos-exporter.ts +++ b/server/core/lib/user-import-export/exporters/videos-exporter.ts @@ -23,11 +23,14 @@ import { VideoModel } from '@server/models/video/video.js' import { MStreamingPlaylistFiles, MThumbnail, - MVideo, MVideoAP, MVideoCaption, + MVideo, + MVideoAP, + MVideoCaption, MVideoCaptionLanguageUrl, MVideoChapter, MVideoFile, - MVideoFullLight, MVideoLiveWithSetting, + MVideoFullLight, + MVideoLiveWithSetting, MVideoPassword } from '@server/types/models/index.js' import { MVideoSource } from '@server/types/models/video/video-source.js' @@ -37,11 +40,12 @@ import { extname, join } from 'path' import { PassThrough, Readable } from 'stream' import { AbstractUserExporter, ExportResult } from './abstract-user-exporter.js' -export class VideosExporter extends AbstractUserExporter { - - constructor (private readonly options: ConstructorParameters>[0] & { - withVideoFiles: boolean - }) { +export class VideosExporter extends AbstractUserExporter { + constructor ( + private readonly options: ConstructorParameters>[0] & { + withVideoFiles: boolean + } + ) { super(options) } @@ -89,10 +93,10 @@ export class VideosExporter extends AbstractUserExporter { const live = video.isLive ? await VideoLiveModel.loadByVideoIdWithSettings(videoId) - : undefined; + : undefined // We already have captions, so we can set it to the video object - (video as any).VideoCaptions = captions + ;(video as any).VideoCaptions = captions // Then fetch more attributes for AP serialization const videoAP = await video.lightAPToFullAP(undefined) @@ -320,7 +324,7 @@ export class VideosExporter extends AbstractUserExporter { const relativePathsFromJSON = { videoFile: null as string, thumbnail: null as string, - captions: {} as { [ lang: string ]: string } + captions: {} as { [lang: string]: string } } if (this.options.withVideoFiles) { @@ -333,9 +337,10 @@ export class VideosExporter extends AbstractUserExporter { archivePath: videoPath, // Prefer using original file if possible - readStreamFactory: () => source?.keptOriginalFilename - ? this.generateVideoSourceReadStream(source) - : this.generateVideoFileReadStream({ video, videoFile, separatedAudioFile }) + readStreamFactory: () => + source?.keptOriginalFilename + ? this.generateVideoSourceReadStream(source) + : this.generateVideoFileReadStream({ video, videoFile, separatedAudioFile }) }) relativePathsFromJSON.videoFile = join(this.relativeStaticDirPath, videoPath) @@ -407,7 +412,7 @@ export class VideosExporter extends AbstractUserExporter { private async generateCaptionReadStream (caption: MVideoCaption): Promise { if (caption.storage === FileStorage.FILE_SYSTEM) { - return createReadStream(caption.getFSPath()) + return createReadStream(caption.getFSFilePath()) } const { stream } = await getCaptionReadStream({ filename: caption.filename, rangeHeader: undefined }) diff --git a/server/core/lib/user-import-export/importers/videos-importer.ts b/server/core/lib/user-import-export/importers/videos-importer.ts index 474a59362..824406271 100644 --- a/server/core/lib/user-import-export/importers/videos-importer.ts +++ b/server/core/lib/user-import-export/importers/videos-importer.ts @@ -37,7 +37,7 @@ import { LocalVideoCreator, ThumbnailOptions } from '@server/lib/local-video-cre import { isLocalVideoFileAccepted } from '@server/lib/moderation.js' import { Hooks } from '@server/lib/plugins/hooks.js' import { isUserQuotaValid } from '@server/lib/user.js' -import { createLocalCaption } from '@server/lib/video-captions.js' +import { createLocalCaption, updateHLSMasterOnCaptionChange } from '@server/lib/video-captions.js' import { buildNextVideoState } from '@server/lib/video-state.js' import { VideoChannelModel } from '@server/models/video/video-channel.js' import { VideoModel } from '@server/models/video/video.js' @@ -49,12 +49,33 @@ import { AbstractUserImporter } from './abstract-user-importer.js' const lTags = loggerTagsFactory('user-import') type ImportObject = VideoExportJSON['videos'][0] -type SanitizedObject = Pick - -export class VideosImporter extends AbstractUserImporter { +type SanitizedObject = Pick< + ImportObject, + | 'name' + | 'duration' + | 'channel' + | 'privacy' + | 'archiveFiles' + | 'captions' + | 'category' + | 'licence' + | 'language' + | 'description' + | 'support' + | 'nsfw' + | 'isLive' + | 'commentsPolicy' + | 'downloadEnabled' + | 'waitTranscoding' + | 'originallyPublishedAt' + | 'tags' + | 'live' + | 'passwords' + | 'source' + | 'chapters' +> +export class VideosImporter extends AbstractUserImporter { protected getImportObjects (json: VideoExportJSON) { return json.videos } @@ -257,6 +278,7 @@ export class VideosImporter extends AbstractUserImporter { return sequelizeTypescript.transaction(t => { return VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t) @@ -56,6 +66,41 @@ export async function createLocalCaption (options: { return Object.assign(videoCaption, { Video: video }) } +// --------------------------------------------------------------------------- + +export async function createAllCaptionPlaylistsOnFSIfNeeded (video: MVideo) { + const captions = await VideoCaptionModel.listVideoCaptions(video.id) + + for (const caption of captions) { + if (caption.m3u8Filename) continue + + try { + caption.m3u8Filename = await upsertCaptionPlaylistOnFS(caption, video) + await caption.save() + } catch (err) { + logger.error( + `Cannot create caption playlist ${caption.filename} (${caption.language}) of video ${video.uuid}`, + { ...lTags(video.uuid), err } + ) + } + } +} + +export async function updateHLSMasterOnCaptionChangeIfNeeded (video: MVideo) { + const hls = await VideoStreamingPlaylistModel.loadHLSByVideo(video.id) + if (!hls) return + + return updateHLSMasterOnCaptionChange(video, hls) +} + +export async function updateHLSMasterOnCaptionChange (video: MVideo, hls: MStreamingPlaylist) { + logger.debug(`Updating HLS master playlist of video ${video.uuid} after caption change`, lTags(video.uuid)) + + await updateM3U8AndShaPlaylist(video, hls) +} + +// --------------------------------------------------------------------------- + export async function createTranscriptionTaskIfNeeded (video: MVideoUUID & MVideoUrl) { if (CONFIG.VIDEO_TRANSCRIPTION.ENABLED !== true) return @@ -186,6 +231,10 @@ export async function onTranscriptionEnded (options: { automaticallyGenerated: true }) + if (caption.m3u8Filename) { + await updateHLSMasterOnCaptionChangeIfNeeded(video) + } + await sequelizeTypescript.transaction(async t => { await federateVideoIfNeeded(video, false, t) }) @@ -194,3 +243,15 @@ export async function onTranscriptionEnded (options: { logger.info(`Transcription ended for ${video.uuid}`, lTags(video.uuid, ...customLTags)) } + +export async function upsertCaptionPlaylistOnFS (caption: MVideoCaption, video: MVideo) { + const m3u8Filename = VideoCaptionModel.generateM3U8Filename(caption.filename) + const m3u8Destination = VideoPathManager.Instance.getFSHLSOutputPath(video, m3u8Filename) + + logger.debug(`Creating caption playlist ${m3u8Destination} of video ${video.uuid}`, lTags(video.uuid)) + + const content = buildCaptionM3U8Content({ video, caption }) + await writeFile(m3u8Destination, content, 'utf8') + + return m3u8Filename +} diff --git a/server/core/lib/video-file.ts b/server/core/lib/video-file.ts index 2a6dde2f0..f2ad7c90d 100644 --- a/server/core/lib/video-file.ts +++ b/server/core/lib/video-file.ts @@ -90,7 +90,7 @@ export async function removeHLSPlaylist (video: MVideoWithAllFiles) { const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) try { - await video.removeStreamingPlaylistFiles(hls) + await video.removeAllStreamingPlaylistFiles({ playlist: hls }) await hls.destroy() video.VideoStreamingPlaylists = video.VideoStreamingPlaylists.filter(p => p.id !== hls.id) diff --git a/server/core/lib/video-path-manager.ts b/server/core/lib/video-path-manager.ts index 06aa2dbec..c26b2569a 100644 --- a/server/core/lib/video-path-manager.ts +++ b/server/core/lib/video-path-manager.ts @@ -11,23 +11,23 @@ import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo, + MVideoPrivacy, MVideoWithFile } from '@server/types/models/index.js' import { Mutex } from 'async-mutex' import { remove } from 'fs-extra/esm' import { extname, join } from 'path' import { makeHLSFileAvailable, makeWebVideoFileAvailable } from './object-storage/index.js' -import { getHLSDirectory, getHlsResolutionPlaylistFilename } from './paths.js' +import { getHLSDirectory, getHLSResolutionPlaylistFilename } from './paths.js' import { isVideoInPrivateDirectory } from './video-privacy.js' -type MakeAvailableCB = (path: string) => Awaitable -type MakeAvailableMultipleCB = (paths: string[]) => Awaitable +type MakeAvailableCB = (path: string) => Awaitable +type MakeAvailableMultipleCB = (paths: string[]) => Awaitable type MakeAvailableCreateMethod = { method: () => Awaitable, clean: boolean } const lTags = loggerTagsFactory('video-path-manager') class VideoPathManager { - private static instance: VideoPathManager // Key is a video UUID @@ -35,7 +35,7 @@ class VideoPathManager { private constructor () {} - getFSHLSOutputPath (video: MVideo, filename?: string) { + getFSHLSOutputPath (video: MVideoPrivacy, filename?: string) { const base = getHLSDirectory(video) if (!filename) return base @@ -62,7 +62,7 @@ class VideoPathManager { // --------------------------------------------------------------------------- - async makeAvailableVideoFiles (videoFiles: (MVideoFileVideo | MVideoFileStreamingPlaylistVideo)[], cb: MakeAvailableMultipleCB) { + async makeAvailableVideoFiles (videoFiles: (MVideoFileVideo | MVideoFileStreamingPlaylistVideo)[], cb: MakeAvailableMultipleCB) { const createMethods: MakeAvailableCreateMethod[] = [] for (const videoFile of videoFiles) { @@ -95,11 +95,11 @@ class VideoPathManager { return this.makeAvailableFactory({ createMethods, cbContext: cb }) } - async makeAvailableVideoFile (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB) { + async makeAvailableVideoFile (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB) { return this.makeAvailableVideoFiles([ videoFile ], paths => cb(paths[0])) } - async makeAvailableMaxQualityFiles ( + async makeAvailableMaxQualityFiles ( video: MVideoWithFile, cb: (options: { videoPath: string, separatedAudioPath: string }) => Awaitable ) { @@ -115,8 +115,8 @@ class VideoPathManager { // --------------------------------------------------------------------------- - async makeAvailableResolutionPlaylistFile (videoFile: MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB) { - const filename = getHlsResolutionPlaylistFilename(videoFile.filename) + async makeAvailableResolutionPlaylistFile (videoFile: MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB) { + const filename = getHLSResolutionPlaylistFilename(videoFile.filename) if (videoFile.storage === FileStorage.FILE_SYSTEM) { return this.makeAvailableFactory({ @@ -142,7 +142,7 @@ class VideoPathManager { }) } - async makeAvailablePlaylistFile (playlist: MStreamingPlaylistVideo, filename: string, cb: MakeAvailableCB) { + async makeAvailablePlaylistFile (playlist: MStreamingPlaylistVideo, filename: string, cb: MakeAvailableCB) { if (playlist.storage === FileStorage.FILE_SYSTEM) { return this.makeAvailableFactory({ createMethods: [ @@ -189,7 +189,7 @@ class VideoPathManager { logger.debug('Released lockfiles of %s.', videoUUID, lTags(videoUUID)) } - private async makeAvailableFactory (options: { + private async makeAvailableFactory (options: { createMethods: MakeAvailableCreateMethod[] cbContext: MakeAvailableMultipleCB }) { diff --git a/server/core/models/redundancy/video-redundancy.ts b/server/core/models/redundancy/video-redundancy.ts index 60c64fb21..f0167f0c5 100644 --- a/server/core/models/redundancy/video-redundancy.ts +++ b/server/core/models/redundancy/video-redundancy.ts @@ -20,7 +20,8 @@ import { CreatedAt, DataType, ForeignKey, - Is, Scopes, + Is, + Scopes, Table, UpdatedAt } from 'sequelize-typescript' @@ -57,7 +58,6 @@ export enum ScopeNames { ] } })) - @Table({ tableName: 'videoRedundancy', indexes: [ @@ -77,7 +77,6 @@ export enum ScopeNames { ] }) export class VideoRedundancyModel extends SequelizeModel { - @CreatedAt createdAt: Date @@ -134,8 +133,8 @@ export class VideoRedundancyModel extends SequelizeModel { const videoUUID = videoStreamingPlaylist.Video.uuid logger.info('Removing duplicated video streaming playlist %s.', videoUUID) - videoStreamingPlaylist.Video.removeStreamingPlaylistFiles(videoStreamingPlaylist, true) - .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err })) + videoStreamingPlaylist.Video.removeAllStreamingPlaylistFiles({ playlist: videoStreamingPlaylist, isRedundancy: true }) + .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err })) return undefined } @@ -295,7 +294,7 @@ export class VideoRedundancyModel extends SequelizeModel { } return VideoRedundancyModel.findOne(query) - .then(r => !!r) + .then(r => !!r) } static async getVideoSample (p: Promise) { @@ -503,7 +502,7 @@ export class VideoRedundancyModel extends SequelizeModel { '(' + 'SELECT "videoId" FROM "videoStreamingPlaylist" ' + 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id' + - ')' + ')' ) } } @@ -516,12 +515,12 @@ export class VideoRedundancyModel extends SequelizeModel { const sql = `WITH "tmp" AS ` + `(` + - `SELECT "videoStreamingFile"."size" AS "videoStreamingFileSize", "videoStreamingPlaylist"."videoId" AS "videoStreamingVideoId"` + - `FROM "videoRedundancy" AS "videoRedundancy" ` + - `LEFT JOIN "videoStreamingPlaylist" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ` + - `LEFT JOIN "videoFile" AS "videoStreamingFile" ` + - `ON "videoStreamingPlaylist"."id" = "videoStreamingFile"."videoStreamingPlaylistId" ` + - `WHERE "videoRedundancy"."strategy" = :strategy AND "videoRedundancy"."actorId" = :actorId` + + `SELECT "videoStreamingFile"."size" AS "videoStreamingFileSize", "videoStreamingPlaylist"."videoId" AS "videoStreamingVideoId"` + + `FROM "videoRedundancy" AS "videoRedundancy" ` + + `LEFT JOIN "videoStreamingPlaylist" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ` + + `LEFT JOIN "videoFile" AS "videoStreamingFile" ` + + `ON "videoStreamingPlaylist"."id" = "videoStreamingFile"."videoStreamingPlaylistId" ` + + `WHERE "videoRedundancy"."strategy" = :strategy AND "videoRedundancy"."actorId" = :actorId` + `) ` + `SELECT ` + `COALESCE(SUM("videoStreamingFileSize"), '0') AS "totalUsed", ` + @@ -604,7 +603,7 @@ export class VideoRedundancyModel extends SequelizeModel { `SELECT "videoStreamingPlaylist"."videoId" AS "videoId" FROM "videoRedundancy" ` + `INNER JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoRedundancy"."videoStreamingPlaylistId" ` + `WHERE "videoRedundancy"."actorId" = ${peertubeActor.id} ` + - ')' + ')' ) return { diff --git a/server/core/models/video/formatter/video-api-format.ts b/server/core/models/video/formatter/video-api-format.ts index f6c22a124..b6c4cbf49 100644 --- a/server/core/models/video/formatter/video-api-format.ts +++ b/server/core/models/video/formatter/video-api-format.ts @@ -12,7 +12,7 @@ import { import { uuidToShort } from '@peertube/peertube-node-utils' import { generateMagnetUri } from '@server/helpers/webtorrent.js' import { tracer } from '@server/lib/opentelemetry/tracing.js' -import { getHlsResolutionPlaylistFilename } from '@server/lib/paths.js' +import { getHLSResolutionPlaylistFilename } from '@server/lib/paths.js' import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js' import { VideoViewsManager } from '@server/lib/views/video-views-manager.js' import { isArray } from '../../../helpers/custom-validators/misc.js' @@ -277,7 +277,7 @@ export function videoFilesModelToFormattedJSON ( hasVideo: videoFile.hasVideo(), playlistUrl: includePlaylistUrl === true - ? getHlsResolutionPlaylistFilename(fileUrl) + ? getHLSResolutionPlaylistFilename(fileUrl) : undefined, storage: video.remote diff --git a/server/core/models/video/video-caption.ts b/server/core/models/video/video-caption.ts index 7c71f1309..d3671b94e 100644 --- a/server/core/models/video/video-caption.ts +++ b/server/core/models/video/video-caption.ts @@ -1,14 +1,19 @@ +import { removeVTTExt } from '@peertube/peertube-core-utils' import { FileStorage, type FileStorageType, VideoCaption, VideoCaptionObject } from '@peertube/peertube-models' import { buildUUID } from '@peertube/peertube-node-utils' import { getObjectStoragePublicFileUrl } from '@server/lib/object-storage/urls.js' -import { removeCaptionObjectStorage } from '@server/lib/object-storage/videos.js' +import { removeCaptionObjectStorage, removeHLSFileObjectStorageByFilename } from '@server/lib/object-storage/videos.js' +import { VideoPathManager } from '@server/lib/video-path-manager.js' import { MVideo, MVideoCaption, + MVideoCaptionFilename, MVideoCaptionFormattable, MVideoCaptionLanguageUrl, + MVideoCaptionUrl, MVideoCaptionVideo, - MVideoOwned + MVideoOwned, + MVideoPrivacy } from '@server/types/models/index.js' import { remove } from 'fs-extra/esm' import { join } from 'path' @@ -22,7 +27,8 @@ import { DataType, Default, ForeignKey, - Is, Scopes, + Is, + Scopes, Table, UpdatedAt } from 'sequelize-typescript' @@ -31,13 +37,14 @@ import { logger } from '../../helpers/logger.js' import { CONFIG } from '../../initializers/config.js' import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants.js' import { SequelizeModel, buildWhereIdOrUUID, doesExist, throwIfNotValid } from '../shared/index.js' +import { VideoStreamingPlaylistModel } from './video-streaming-playlist.js' import { VideoModel } from './video.js' export enum ScopeNames { CAPTION_WITH_VIDEO = 'CAPTION_WITH_VIDEO' } -const videoAttributes = [ 'id', 'name', 'remote', 'uuid', 'url', 'state' ] +const videoAttributes = [ 'id', 'name', 'remote', 'uuid', 'url', 'state', 'privacy' ] @Scopes(() => ({ [ScopeNames.CAPTION_WITH_VIDEO]: { @@ -50,7 +57,6 @@ const videoAttributes = [ 'id', 'name', 'remote', 'uuid', 'url', 'state' ] ] } })) - @Table({ tableName: 'videoCaption', indexes: [ @@ -83,6 +89,10 @@ export class VideoCaptionModel extends SequelizeModel { @Column filename: string + @AllowNull(true) + @Column + m3u8Filename: string + @AllowNull(false) @Default(FileStorage.FILE_SYSTEM) @Column @@ -92,6 +102,10 @@ export class VideoCaptionModel extends SequelizeModel { @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max)) fileUrl: string + @AllowNull(true) + @Column + m3u8Url: string + @AllowNull(false) @Column automaticallyGenerated: boolean @@ -117,11 +131,8 @@ export class VideoCaptionModel extends SequelizeModel { if (instance.isOwned()) { logger.info('Removing caption %s.', instance.filename) - try { - await instance.removeCaptionFile() - } catch (err) { - logger.error('Cannot remove caption file %s.', instance.filename) - } + instance.removeAllCaptionFiles() + .catch(err => logger.error('Cannot remove caption file ' + instance.filename, { err })) } return undefined @@ -230,7 +241,7 @@ export class VideoCaptionModel extends SequelizeModel { } const captions = await VideoCaptionModel.scope(ScopeNames.CAPTION_WITH_VIDEO).findAll(query) - const result: { [ id: number ]: MVideoCaptionVideo[] } = {} + const result: { [id: number]: MVideoCaptionVideo[] } = {} for (const id of videoIds) { result[id] = [] @@ -253,6 +264,10 @@ export class VideoCaptionModel extends SequelizeModel { return `${buildUUID()}-${language}.vtt` } + static generateM3U8Filename (vttFilename: string) { + return removeVTTExt(vttFilename) + '.m3u8' + } + // --------------------------------------------------------------------------- toFormattedJSON (this: MVideoCaptionFormattable): VideoCaption { @@ -265,9 +280,10 @@ export class VideoCaptionModel extends SequelizeModel { captionPath: this.Video.isOwned() && this.fileUrl ? null // On object storage - : this.getCaptionStaticPath(), + : this.getFileStaticPath(), fileUrl: this.getFileUrl(this.Video), + m3u8Url: this.getM3U8Url(this.Video), updatedAt: this.updatedAt.toISOString() } @@ -278,7 +294,22 @@ export class VideoCaptionModel extends SequelizeModel { identifier: this.language, name: VideoCaptionModel.getLanguageLabel(this.language), automaticallyGenerated: this.automaticallyGenerated, - url: this.getOriginFileUrl(video) + + // TODO: Remove break flag in v8 + url: process.env.ENABLE_AP_BREAKING_CHANGES === 'true' + ? [ + { + type: 'Link', + mediaType: 'text/vtt', + href: this.getOriginFileUrl(video) + }, + { + type: 'Link', + mediaType: 'application/x-mpegURL', + href: this.getOriginFileUrl(video) + } + ] + : this.getOriginFileUrl(video) } } @@ -288,33 +319,77 @@ export class VideoCaptionModel extends SequelizeModel { return this.Video.remote === false } - getCaptionStaticPath (this: MVideoCaptionLanguageUrl) { + // --------------------------------------------------------------------------- + + getFileStaticPath (this: MVideoCaptionFilename) { return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename) } - getFSPath () { - return join(CONFIG.STORAGE.CAPTIONS_DIR, this.filename) - } + getM3U8StaticPath (this: MVideoCaptionFilename, video: MVideoPrivacy) { + if (!this.m3u8Filename) return null - removeCaptionFile (this: MVideoCaption) { - if (this.storage === FileStorage.OBJECT_STORAGE) { - return removeCaptionObjectStorage(this) - } - - return remove(this.getFSPath()) + return VideoStreamingPlaylistModel.getPlaylistFileStaticPath(video, this.m3u8Filename) } // --------------------------------------------------------------------------- - getFileUrl (this: MVideoCaptionLanguageUrl, video: MVideoOwned) { + getFSFilePath () { + return join(CONFIG.STORAGE.CAPTIONS_DIR, this.filename) + } + + getFSM3U8Path (video: MVideoPrivacy) { + if (!this.m3u8Filename) return null + + return VideoPathManager.Instance.getFSHLSOutputPath(video, this.m3u8Filename) + } + + async removeAllCaptionFiles (this: MVideoCaptionVideo) { + await this.removeCaptionFile() + await this.removeCaptionPlaylist() + } + + async removeCaptionFile (this: MVideoCaptionVideo) { + if (this.storage === FileStorage.OBJECT_STORAGE) { + if (this.fileUrl) { + await removeCaptionObjectStorage(this) + } + } else { + await remove(this.getFSFilePath()) + } + + this.filename = null + this.fileUrl = null + } + + async removeCaptionPlaylist (this: MVideoCaptionVideo) { + if (!this.m3u8Filename) return + + const hls = await VideoStreamingPlaylistModel.loadHLSByVideoWithVideo(this.videoId) + if (!hls) return + + if (this.storage === FileStorage.OBJECT_STORAGE) { + if (this.m3u8Url) { + await removeHLSFileObjectStorageByFilename(hls, this.m3u8Filename) + } + } else { + await remove(this.getFSM3U8Path(this.Video)) + } + + this.m3u8Filename = null + this.m3u8Url = null + } + + // --------------------------------------------------------------------------- + + getFileUrl (this: MVideoCaptionUrl, video: MVideoOwned) { if (video.isOwned() && this.storage === FileStorage.OBJECT_STORAGE) { return getObjectStoragePublicFileUrl(this.fileUrl, CONFIG.OBJECT_STORAGE.CAPTIONS) } - return WEBSERVER.URL + this.getCaptionStaticPath() + return WEBSERVER.URL + this.getFileStaticPath() } - getOriginFileUrl (this: MVideoCaptionLanguageUrl, video: MVideoOwned) { + getOriginFileUrl (this: MVideoCaptionUrl, video: MVideoOwned) { if (video.isOwned()) return this.getFileUrl(video) return this.fileUrl @@ -322,6 +397,22 @@ export class VideoCaptionModel extends SequelizeModel { // --------------------------------------------------------------------------- + getM3U8Url (this: MVideoCaptionUrl, video: MVideoOwned & MVideoPrivacy) { + if (!this.m3u8Filename) return null + + if (video.isOwned()) { + if (this.storage === FileStorage.OBJECT_STORAGE) { + return getObjectStoragePublicFileUrl(this.m3u8Url, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) + } + + return WEBSERVER.URL + this.getM3U8StaticPath(video) + } + + return this.m3u8Url + } + + // --------------------------------------------------------------------------- + isEqual (this: MVideoCaption, other: MVideoCaption) { if (this.fileUrl) return this.fileUrl === other.fileUrl diff --git a/server/core/models/video/video-streaming-playlist.ts b/server/core/models/video/video-streaming-playlist.ts index 61b7a250a..802993819 100644 --- a/server/core/models/video/video-streaming-playlist.ts +++ b/server/core/models/video/video-streaming-playlist.ts @@ -12,22 +12,18 @@ import { getHLSPrivateFileUrl, getObjectStoragePublicFileUrl } from '@server/lib import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths.js' import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js' import { VideoFileModel } from '@server/models/video/video-file.js' -import { MStreamingPlaylist, MStreamingPlaylistFiles, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js' +import { + MStreamingPlaylist, + MStreamingPlaylistFiles, + MStreamingPlaylistFilesVideo, + MStreamingPlaylistVideo, + MVideo, + MVideoPrivacy +} from '@server/types/models/index.js' import memoizee from 'memoizee' import { join } from 'path' import { Op, Transaction } from 'sequelize' -import { - AllowNull, - BelongsTo, - Column, - CreatedAt, - DataType, - Default, - ForeignKey, - HasMany, - Is, Table, - UpdatedAt -} from 'sequelize-typescript' +import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, Is, Table, UpdatedAt } from 'sequelize-typescript' import { isArrayOf } from '../../helpers/custom-validators/misc.js' import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos.js' import { @@ -205,7 +201,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel { + static loadHLSByVideo (videoId: number, transaction?: Transaction): Promise { const options = { where: { type: VideoStreamingPlaylistType.HLS, @@ -217,10 +213,31 @@ export class VideoStreamingPlaylistModel extends SequelizeModel { + const options = { + where: { + type: VideoStreamingPlaylistType.HLS, + videoId + }, + include: [ + { + model: VideoModel.unscoped(), + required: true + } + ], + transaction + } + + return VideoStreamingPlaylistModel.findOne(options) + } + static async loadOrGenerate (video: MVideo, transaction?: Transaction) { - let playlist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id, transaction) + let playlist = await VideoStreamingPlaylistModel.loadHLSByVideo(video.id, transaction) + let generated = false if (!playlist) { + generated = true + playlist = new VideoStreamingPlaylistModel({ p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION, type: VideoStreamingPlaylistType.HLS, @@ -234,7 +251,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel { name: 'videoId', allowNull: true }, - hooks: true, onDelete: 'cascade' }) VideoFiles: Awaited[] @@ -650,7 +649,6 @@ export class VideoModel extends SequelizeModel { name: 'videoId', allowNull: false }, - hooks: true, onDelete: 'cascade' }) VideoStreamingPlaylists: Awaited[] @@ -834,7 +832,7 @@ export class VideoModel extends SequelizeModel { static async removeFiles (instance: VideoModel, options) { const tasks: Promise[] = [] - logger.info('Removing files of video %s.', instance.url) + logger.info('Removing files of video %s.', instance.url, { toto: new Error().stack }) if (instance.isOwned()) { if (!Array.isArray(instance.VideoFiles)) { @@ -852,7 +850,8 @@ export class VideoModel extends SequelizeModel { } for (const p of instance.VideoStreamingPlaylists) { - tasks.push(instance.removeStreamingPlaylistFiles(p)) + // Captions will be automatically deleted + tasks.push(instance.removeAllStreamingPlaylistFiles({ playlist: p, deleteCaptionPlaylists: false })) } // Remove source files @@ -1904,7 +1903,7 @@ export class VideoModel extends SequelizeModel { if (isArray(videoAP.VideoCaptions)) return videoAP.VideoCaptions return this.$get('VideoCaptions', { - attributes: [ 'filename', 'language', 'fileUrl', 'storage', 'automaticallyGenerated' ], + attributes: [ 'filename', 'language', 'fileUrl', 'storage', 'automaticallyGenerated', 'm3u8Filename', 'm3u8Url' ], transaction }) as Promise } @@ -1993,47 +1992,76 @@ export class VideoModel extends SequelizeModel { return Promise.all(promises) } - async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) { + async removeAllStreamingPlaylistFiles (options: { + playlist: MStreamingPlaylist + deleteCaptionPlaylists?: boolean // default true + isRedundancy?: boolean // default false + }) { + const { playlist, deleteCaptionPlaylists = true, isRedundancy = false } = options + const directoryPath = isRedundancy ? getHLSRedundancyDirectory(this) : getHLSDirectory(this) - try { - await remove(directoryPath) - } catch (err) { - // If it's a live, ffmpeg may have added another file while fs-extra is removing the directory - // So wait a little bit and retry - if (err.code === 'ENOTEMPTY') { - await wait(1000) + const removeDirectory = async () => { + try { await remove(directoryPath) + } catch (err) { + // If it's a live, ffmpeg may have added another file while fs-extra is removing the directory + // So wait a little bit and retry + if (err.code === 'ENOTEMPTY') { + await wait(1000) + await remove(directoryPath) - return + return + } + + throw err } - - throw err } - if (isRedundancy !== true) { - const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo - streamingPlaylistWithFiles.Video = this + if (isRedundancy) { + await removeDirectory() + } else { + if (deleteCaptionPlaylists) { + const captions = await VideoCaptionModel.listVideoCaptions(playlist.videoId) - if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) { - streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles') + // Remove playlist files associated to captions + for (const caption of captions) { + try { + await caption.removeCaptionPlaylist() + await caption.save() + } catch (err) { + logger.error( + `Cannot remove caption ${caption.filename} (${caption.language}) playlist files associated to video ${this.name}`, + { video: this, ...lTags(this.uuid) } + ) + } + } + } + + await removeDirectory() + + const playlistWithFiles = playlist as MStreamingPlaylistFilesVideo + playlistWithFiles.Video = this + + if (!Array.isArray(playlistWithFiles.VideoFiles)) { + playlistWithFiles.VideoFiles = await playlistWithFiles.$get('VideoFiles') } // Remove physical files and torrents await Promise.all( - streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent()) + playlistWithFiles.VideoFiles.map(file => file.removeTorrent()) ) - if (streamingPlaylist.storage === FileStorage.OBJECT_STORAGE) { - await removeHLSObjectStorage(streamingPlaylist.withVideo(this)) + if (playlist.storage === FileStorage.OBJECT_STORAGE) { + await removeHLSObjectStorage(playlist.withVideo(this)) } } logger.debug( `Removing files associated to streaming playlist of video ${this.url}`, - { streamingPlaylist, isRedundancy, ...lTags(this.uuid) } + { playlist, isRedundancy, ...lTags(this.uuid) } ) } @@ -2042,7 +2070,7 @@ export class VideoModel extends SequelizeModel { await videoFile.removeTorrent() await remove(filePath) - const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename) + const resolutionFilename = getHLSResolutionPlaylistFilename(videoFile.filename) await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename)) if (videoFile.storage === FileStorage.OBJECT_STORAGE) { diff --git a/server/core/types/models/video/video-caption.ts b/server/core/types/models/video/video-caption.ts index 2f9c75510..278ca15aa 100644 --- a/server/core/types/models/video/video-caption.ts +++ b/server/core/types/models/video/video-caption.ts @@ -1,6 +1,6 @@ import { PickWith } from '@peertube/peertube-typescript-utils' import { VideoCaptionModel } from '../../../models/video/video-caption.js' -import { MVideo, MVideoOwned, MVideoUUID } from './video.js' +import { MVideo, MVideoOwned, MVideoPrivacy } from './video.js' type Use = PickWith @@ -11,6 +11,13 @@ export type MVideoCaption = Omit // ############################################################################ export type MVideoCaptionLanguage = Pick +export type MVideoCaptionFilename = Pick + +export type MVideoCaptionUrl = Pick< + MVideoCaption, + 'filename' | 'getFileStaticPath' | 'storage' | 'fileUrl' | 'm3u8Url' | 'getFileUrl' | 'getM3U8Url' | 'm3u8Filename' | 'getM3U8StaticPath' +> + export type MVideoCaptionLanguageUrl = Pick< MVideoCaption, | 'language' @@ -18,15 +25,19 @@ export type MVideoCaptionLanguageUrl = Pick< | 'storage' | 'filename' | 'automaticallyGenerated' - | 'getFileUrl' - | 'getCaptionStaticPath' + | 'm3u8Filename' + | 'm3u8Url' | 'toActivityPubObject' + | 'getFileUrl' + | 'getFileStaticPath' | 'getOriginFileUrl' + | 'getM3U8Url' + | 'getM3U8StaticPath' > export type MVideoCaptionVideo = & MVideoCaption - & Use<'Video', Pick> + & Use<'Video', Pick> // ############################################################################ @@ -35,4 +46,4 @@ export type MVideoCaptionVideo = export type MVideoCaptionFormattable = & MVideoCaption & Pick - & Use<'Video', MVideoOwned & MVideoUUID> + & Use<'Video', MVideoOwned & MVideoPrivacy> diff --git a/server/core/types/models/video/video-streaming-playlist.ts b/server/core/types/models/video/video-streaming-playlist.ts index d3a54e131..340858877 100644 --- a/server/core/types/models/video/video-streaming-playlist.ts +++ b/server/core/types/models/video/video-streaming-playlist.ts @@ -1,6 +1,6 @@ import { PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils' import { VideoStreamingPlaylistModel } from '../../../models/video/video-streaming-playlist.js' -import { MVideo } from './video.js' +import { MVideo, MVideoUUID } from './video.js' import { MVideoFile } from './video-file.js' import { MVideoRedundancy, MVideoRedundancyFileUrl } from './video-redundancy.js' @@ -18,6 +18,10 @@ export type MStreamingPlaylistVideo = & MStreamingPlaylist & Use<'Video', MVideo> +export type MStreamingPlaylistVideoUUID = + & MStreamingPlaylist + & Use<'Video', MVideoUUID> + export type MStreamingPlaylistFilesVideo = & MStreamingPlaylist & Use<'VideoFiles', MVideoFile[]> diff --git a/server/core/types/models/video/video.ts b/server/core/types/models/video/video.ts index 4d3e9e0b4..cdbf26f1b 100644 --- a/server/core/types/models/video/video.ts +++ b/server/core/types/models/video/video.ts @@ -61,6 +61,7 @@ export type MVideo = Omit< export type MVideoId = Pick export type MVideoUrl = Pick export type MVideoUUID = Pick +export type MVideoPrivacy = Pick export type MVideoImmutable = Pick export type MVideoOwned = Pick diff --git a/server/scripts/migrations/peertube-4.0.ts b/server/scripts/migrations/peertube-4.0.ts index 619c1da71..34b5449f6 100644 --- a/server/scripts/migrations/peertube-4.0.ts +++ b/server/scripts/migrations/peertube-4.0.ts @@ -8,7 +8,7 @@ import { JobQueue } from '@server/lib/job-queue/index.js' import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, - getHlsResolutionPlaylistFilename + getHLSResolutionPlaylistFilename } from '@server/lib/paths.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js' @@ -19,7 +19,6 @@ run() .catch(err => { console.error(err) process.exit(-1) - }) async function run () { @@ -52,7 +51,7 @@ async function processVideo (videoId: number) { console.log(`Renaming HLS playlist files of video ${video.name}.`) - const playlist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id) + const playlist = await VideoStreamingPlaylistModel.loadHLSByVideo(video.id) const hlsDirPath = VideoPathManager.Instance.getFSHLSOutputPath(video) const masterPlaylistPath = join(hlsDirPath, playlist.playlistFilename) @@ -60,7 +59,7 @@ async function processVideo (videoId: number) { for (const videoFile of hls.VideoFiles) { const srcName = `${videoFile.resolution}.m3u8` - const dstName = getHlsResolutionPlaylistFilename(videoFile.filename) + const dstName = getHLSResolutionPlaylistFilename(videoFile.filename) const src = join(hlsDirPath, srcName) const dst = join(hlsDirPath, dstName)