1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-05 02:39:33 +02:00

Separate HLS audio and video streams

Allows:
  * The HLS player to propose an "Audio only" resolution
  * The live to output an "Audio only" resolution
  * The live to ingest and output an "Audio only" stream

 This feature is under a config for VOD videos and is enabled by default for lives

 In the future we can imagine:
  * To propose multiple audio streams for a specific video
  * To ingest an audio only VOD and just output an audio only "video"
    (the player would play the audio file and PeerTube would not
    generate additional resolutions)

This commit introduce a new way to download videos:
 * Add "/download/videos/generate/:videoId" endpoint where PeerTube can
   mux an audio only and a video only file to a mp4 container
 * The download client modal introduces a new default panel where the
   user can choose resolutions it wants to download
This commit is contained in:
Chocobozzz 2024-07-23 16:38:51 +02:00 committed by Chocobozzz
parent e77ba2dfbc
commit 816f346a60
186 changed files with 5748 additions and 2807 deletions

View file

@ -8,7 +8,7 @@ export type ExportResult <T> = {
staticFiles: {
archivePath: string
createrReadStream: () => Promise<Readable>
readStreamFactory: () => Promise<Readable>
}[]
activityPub?: ActivityPubActor | ActivityPubOrderedCollection<string>

View file

@ -59,7 +59,7 @@ export abstract class ActorExporter <T> extends AbstractUserExporter<T> {
staticFiles.push({
archivePath: archivePathBuilder(image.filename),
createrReadStream: () => Promise.resolve(createReadStream(image.getPath()))
readStreamFactory: () => Promise.resolve(createReadStream(image.getPath()))
})
const relativePath = join(this.relativeStaticDirPath, archivePathBuilder(image.filename))

View file

@ -26,7 +26,7 @@ export class VideoPlaylistsExporter extends AbstractUserExporter <VideoPlaylists
staticFiles.push({
archivePath: this.getArchiveThumbnailPath(playlist, thumbnail),
createrReadStream: () => Promise.resolve(createReadStream(thumbnail.getPath()))
readStreamFactory: () => Promise.resolve(createReadStream(thumbnail.getPath()))
})
archiveFiles.thumbnail = join(this.relativeStaticDirPath, this.getArchiveThumbnailPath(playlist, thumbnail))

View file

@ -6,6 +6,7 @@ import { audiencify, getAudience } from '@server/lib/activitypub/audience.js'
import { buildCreateActivity } from '@server/lib/activitypub/send/send-create.js'
import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js'
import { getHLSFileReadStream, getOriginalFileReadStream, getWebVideoFileReadStream } from '@server/lib/object-storage/videos.js'
import { muxToMergeVideoFiles } from '@server/lib/video-file.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
@ -16,7 +17,8 @@ import { VideoSourceModel } from '@server/models/video/video-source.js'
import { VideoModel } from '@server/models/video/video.js'
import {
MStreamingPlaylistFiles,
MThumbnail, MVideo, MVideoAP, MVideoCaption,
MThumbnail,
MVideo, MVideoAP, MVideoCaption,
MVideoCaptionLanguageUrl,
MVideoChapter,
MVideoFile,
@ -27,7 +29,7 @@ import { MVideoSource } from '@server/types/models/video/video-source.js'
import Bluebird from 'bluebird'
import { createReadStream } from 'fs'
import { extname, join } from 'path'
import { Readable } from 'stream'
import { PassThrough, Readable } from 'stream'
import { AbstractUserExporter, ExportResult } from './abstract-user-exporter.js'
export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
@ -89,13 +91,13 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
// Then fetch more attributes for AP serialization
const videoAP = await video.lightAPToFullAP(undefined)
const { relativePathsFromJSON, staticFiles } = await this.exportVideoFiles({ video, captions })
const { relativePathsFromJSON, staticFiles, exportedVideoFileOrSource } = await this.exportVideoFiles({ video, captions })
return {
json: this.exportVideoJSON({ video, captions, live, passwords, source, chapters, archiveFiles: relativePathsFromJSON }),
staticFiles,
relativePathsFromJSON,
activityPubOutbox: await this.exportVideoAP(videoAP, chapters)
activityPubOutbox: await this.exportVideoAP(videoAP, chapters, exportedVideoFileOrSource)
}
}
@ -250,8 +252,11 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
// ---------------------------------------------------------------------------
private async exportVideoAP (video: MVideoAP, chapters: MVideoChapter[]): Promise<ActivityCreate<VideoObject>> {
const videoFile = video.getMaxQualityFile()
private async exportVideoAP (
video: MVideoAP,
chapters: MVideoChapter[],
exportedVideoFileOrSource: MVideoFile | MVideoSource
): Promise<ActivityCreate<VideoObject>> {
const icon = video.getPreview()
const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC)
@ -274,13 +279,19 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
hasParts: buildChaptersAPHasPart(video, chapters),
attachment: this.options.withVideoFiles && videoFile
attachment: this.options.withVideoFiles && exportedVideoFileOrSource
? [
{
type: 'Video' as 'Video',
url: join(this.options.relativeStaticDirPath, this.getArchiveVideoFilePath(video, videoFile)),
url: join(this.options.relativeStaticDirPath, this.getArchiveVideoFilePath(video, exportedVideoFileOrSource)),
...pick(videoFile.toActivityPubObject(video), [ 'mediaType', 'height', 'size', 'fps' ])
// FIXME: typings
...pick((exportedVideoFileOrSource as MVideoFile & MVideoSource).toActivityPubObject(video), [
'mediaType',
'height',
'size',
'fps'
])
}
]
: undefined
@ -298,6 +309,9 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
const { video, captions } = options
const staticFiles: ExportResult<VideoExportJSON>['staticFiles'] = []
let exportedVideoFileOrSource: MVideoFile | MVideoSource
const relativePathsFromJSON = {
videoFile: null as string,
thumbnail: null as string,
@ -305,32 +319,32 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
}
if (this.options.withVideoFiles) {
const source = await VideoSourceModel.loadLatest(video.id)
const maxQualityFile = video.getMaxQualityFile()
const { source, videoFile, separatedAudioFile } = await this.getArchiveVideo(video)
// Prefer using original file if possible
const file = source?.keptOriginalFilename
? source
: maxQualityFile
if (file) {
const videoPath = this.getArchiveVideoFilePath(video, file)
if (source || videoFile || separatedAudioFile) {
const videoPath = this.getArchiveVideoFilePath(video, source || videoFile || separatedAudioFile)
staticFiles.push({
archivePath: videoPath,
createrReadStream: () => file === source
// Prefer using original file if possible
readStreamFactory: () => source?.keptOriginalFilename
? this.generateVideoSourceReadStream(source)
: this.generateVideoFileReadStream(video, maxQualityFile)
: this.generateVideoFileReadStream({ video, videoFile, separatedAudioFile })
})
relativePathsFromJSON.videoFile = join(this.relativeStaticDirPath, videoPath)
exportedVideoFileOrSource = source?.keptOriginalFilename
? source
: videoFile || separatedAudioFile
}
}
for (const caption of captions) {
staticFiles.push({
archivePath: this.getArchiveCaptionFilePath(video, caption),
createrReadStream: () => Promise.resolve(createReadStream(caption.getFSPath()))
readStreamFactory: () => Promise.resolve(createReadStream(caption.getFSPath()))
})
relativePathsFromJSON.captions[caption.language] = join(this.relativeStaticDirPath, this.getArchiveCaptionFilePath(video, caption))
@ -340,13 +354,13 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
if (thumbnail) {
staticFiles.push({
archivePath: this.getArchiveThumbnailFilePath(video, thumbnail),
createrReadStream: () => Promise.resolve(createReadStream(thumbnail.getPath()))
readStreamFactory: () => Promise.resolve(createReadStream(thumbnail.getPath()))
})
relativePathsFromJSON.thumbnail = join(this.relativeStaticDirPath, this.getArchiveThumbnailFilePath(video, thumbnail))
}
return { staticFiles, relativePathsFromJSON }
return { staticFiles, relativePathsFromJSON, exportedVideoFileOrSource }
}
private async generateVideoSourceReadStream (source: MVideoSource): Promise<Readable> {
@ -359,7 +373,22 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
return stream
}
private async generateVideoFileReadStream (video: MVideoFullLight, videoFile: MVideoFile): Promise<Readable> {
private async generateVideoFileReadStream (options: {
videoFile: MVideoFile
separatedAudioFile: MVideoFile
video: MVideoFullLight
}): Promise<Readable> {
const { video, videoFile, separatedAudioFile } = options
if (separatedAudioFile) {
const stream = new PassThrough()
muxToMergeVideoFiles({ video, videoFiles: [ videoFile, separatedAudioFile ], output: stream })
.catch(err => logger.error('Cannot mux video files', { err }))
return Promise.resolve(stream)
}
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
return createReadStream(VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile))
}
@ -371,8 +400,18 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
return stream
}
private getArchiveVideoFilePath (video: MVideo, file: { filename?: string, keptOriginalFilename?: string }) {
return join('video-files', video.uuid + extname(file.filename || file.keptOriginalFilename))
private async getArchiveVideo (video: MVideoFullLight) {
const source = await VideoSourceModel.loadLatest(video.id)
const { videoFile, separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles()
if (source?.keptOriginalFilename) return { source }
return { videoFile, separatedAudioFile }
}
private getArchiveVideoFilePath (video: MVideo, file: { keptOriginalFilename?: string, filename?: string }) {
return join('video-files', video.uuid + extname(file.keptOriginalFilename || file.filename))
}
private getArchiveCaptionFilePath (video: MVideo, caption: MVideoCaptionLanguageUrl) {

View file

@ -114,7 +114,7 @@ export class UserExporter {
return new Promise<void>(async (res, rej) => {
this.archive.on('warning', err => {
logger.warn('Warning to archive a file in ' + exportModel.filename, { err })
logger.warn('Warning to archive a file in ' + exportModel.filename, { ...lTags(), err })
})
this.archive.on('error', err => {
@ -127,7 +127,7 @@ export class UserExporter {
for (const { exporter, jsonFilename } of this.buildExporters(exportModel, user)) {
const { json, staticFiles, activityPub, activityPubOutbox } = await exporter.export()
logger.debug('Adding JSON file ' + jsonFilename + ' in archive ' + exportModel.filename)
logger.debug(`Adding JSON file ${jsonFilename} in archive ${exportModel.filename}`, lTags())
this.appendJSON(json, join('peertube', jsonFilename))
if (activityPub) {
@ -144,12 +144,12 @@ export class UserExporter {
for (const file of staticFiles) {
const archivePath = join('files', parse(jsonFilename).name, file.archivePath)
logger.debug(`Adding static file ${archivePath} in archive`)
logger.debug(`Adding static file ${archivePath} in archive`, lTags())
try {
await this.addToArchiveAndWait(await file.createrReadStream(), archivePath)
await this.addToArchiveAndWait(await file.readStreamFactory(), archivePath)
} catch (err) {
logger.error(`Cannot add ${archivePath} in archive`, { err })
logger.error(`Cannot add ${archivePath} in archive`, { err, ...lTags() })
}
}
}
@ -287,10 +287,14 @@ export class UserExporter {
this.archive.on('entry', entryListener)
logger.error('Adding stream ' + archivePath)
// Prevent sending a stream that has an error on open resulting in a stucked archiving process
stream.once('readable', () => {
if (errored) return
logger.error('Readable stream ' + archivePath)
this.archive.append(stream, { name: archivePath })
})
})

View file

@ -73,7 +73,7 @@ export class UserImporter {
importModel.resultSummary = resultSummary
await saveInTransactionWithRetries(importModel)
} catch (err) {
logger.error('Cannot import user archive', { toto: 'coucou', err, ...lTags() })
logger.error('Cannot import user archive', { err, ...lTags() })
try {
importModel.state = UserImportState.ERRORED