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

@ -193,7 +193,7 @@ npm run dev
### Embed ### Embed
The embed is a standalone application built using Vite. The embed is a standalone application built using Vite.
The generated files (HTML entrypoint and multiple JS and CSS files) are served by the PeerTube server (behind `localhost:9000/videos/embed/:videoUUID` or `localhost:9000/video-playlists/embed/:playlistUUID`). The generated files (HTML entrypoint and multiple JS and CSS files) are served by the Vite server (behind `localhost:5173/videos/embed/:videoUUID` or `localhost:5173/video-playlists/embed/:playlistUUID`).
The following command will compile embed files and run the PeerTube server: The following command will compile embed files and run the PeerTube server:
``` ```

View File

@ -1,9 +1,10 @@
import { remove } from 'fs-extra/esm' import { pick } from '@peertube/peertube-core-utils'
import { join } from 'path'
import { FFmpegEdition, FFmpegLive, FFmpegVOD, getDefaultAvailableEncoders, getDefaultEncodersToTry } from '@peertube/peertube-ffmpeg' import { FFmpegEdition, FFmpegLive, FFmpegVOD, getDefaultAvailableEncoders, getDefaultEncodersToTry } from '@peertube/peertube-ffmpeg'
import { RunnerJob, RunnerJobPayload } from '@peertube/peertube-models' import { RunnerJob, RunnerJobPayload } from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils' import { buildUUID } from '@peertube/peertube-node-utils'
import { PeerTubeServer } from '@peertube/peertube-server-commands' import { PeerTubeServer } from '@peertube/peertube-server-commands'
import { remove } from 'fs-extra/esm'
import { join } from 'path'
import { ConfigManager, downloadFile, logger } from '../../../shared/index.js' import { ConfigManager, downloadFile, logger } from '../../../shared/index.js'
import { getWinstonLogger } from './winston-logger.js' import { getWinstonLogger } from './winston-logger.js'
@ -35,6 +36,18 @@ export async function downloadInputFile (options: {
return destination return destination
} }
export async function downloadSeparatedAudioFileIfNeeded (options: {
urls: string[]
job: JobWithToken
runnerToken: string
}) {
const { urls } = options
if (!urls || urls.length === 0) return undefined
return downloadInputFile({ url: urls[0], ...pick(options, [ 'job', 'runnerToken' ]) })
}
export function scheduleTranscodingProgress (options: { export function scheduleTranscodingProgress (options: {
server: PeerTubeServer server: PeerTubeServer
runnerToken: string runnerToken: string

View File

@ -1,9 +1,11 @@
import { FSWatcher, watch } from 'chokidar'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { ensureDir, remove } from 'fs-extra/esm'
import { basename, join } from 'path'
import { wait } from '@peertube/peertube-core-utils' import { wait } from '@peertube/peertube-core-utils'
import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '@peertube/peertube-ffmpeg' import {
ffprobePromise,
getVideoStreamBitrate,
getVideoStreamDimensionsInfo,
hasAudioStream,
hasVideoStream
} from '@peertube/peertube-ffmpeg'
import { import {
LiveRTMPHLSTranscodingSuccess, LiveRTMPHLSTranscodingSuccess,
LiveRTMPHLSTranscodingUpdatePayload, LiveRTMPHLSTranscodingUpdatePayload,
@ -12,6 +14,10 @@ import {
ServerErrorCode ServerErrorCode
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils' import { buildUUID } from '@peertube/peertube-node-utils'
import { FSWatcher, watch } from 'chokidar'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { ensureDir, remove } from 'fs-extra/esm'
import { basename, join } from 'path'
import { ConfigManager } from '../../../shared/config-manager.js' import { ConfigManager } from '../../../shared/config-manager.js'
import { logger } from '../../../shared/index.js' import { logger } from '../../../shared/index.js'
import { buildFFmpegLive, ProcessOptions } from './common.js' import { buildFFmpegLive, ProcessOptions } from './common.js'
@ -51,6 +57,7 @@ export class ProcessLiveRTMPHLSTranscoding {
logger.info({ probe }, `Probed ${payload.input.rtmpUrl}`) logger.info({ probe }, `Probed ${payload.input.rtmpUrl}`)
const hasAudio = await hasAudioStream(payload.input.rtmpUrl, probe) const hasAudio = await hasAudioStream(payload.input.rtmpUrl, probe)
const hasVideo = await hasVideoStream(payload.input.rtmpUrl, probe)
const bitrate = await getVideoStreamBitrate(payload.input.rtmpUrl, probe) const bitrate = await getVideoStreamBitrate(payload.input.rtmpUrl, probe)
const { ratio } = await getVideoStreamDimensionsInfo(payload.input.rtmpUrl, probe) const { ratio } = await getVideoStreamDimensionsInfo(payload.input.rtmpUrl, probe)
@ -103,11 +110,13 @@ export class ProcessLiveRTMPHLSTranscoding {
segmentDuration: payload.output.segmentDuration, segmentDuration: payload.output.segmentDuration,
toTranscode: payload.output.toTranscode, toTranscode: payload.output.toTranscode,
splitAudioAndVideo: true,
bitrate, bitrate,
ratio, ratio,
hasAudio, hasAudio,
hasVideo,
probe probe
}) })

View File

@ -1,5 +1,3 @@
import { remove } from 'fs-extra/esm'
import { join } from 'path'
import { pick } from '@peertube/peertube-core-utils' import { pick } from '@peertube/peertube-core-utils'
import { import {
RunnerJobStudioTranscodingPayload, RunnerJobStudioTranscodingPayload,
@ -12,17 +10,30 @@ import {
VideoStudioTranscodingSuccess VideoStudioTranscodingSuccess
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils' import { buildUUID } from '@peertube/peertube-node-utils'
import { remove } from 'fs-extra/esm'
import { join } from 'path'
import { ConfigManager } from '../../../shared/config-manager.js' import { ConfigManager } from '../../../shared/config-manager.js'
import { logger } from '../../../shared/index.js' import { logger } from '../../../shared/index.js'
import { buildFFmpegEdition, downloadInputFile, JobWithToken, ProcessOptions, scheduleTranscodingProgress } from './common.js' import {
buildFFmpegEdition,
downloadInputFile,
downloadSeparatedAudioFileIfNeeded,
JobWithToken,
ProcessOptions,
scheduleTranscodingProgress
} from './common.js'
export async function processStudioTranscoding (options: ProcessOptions<RunnerJobStudioTranscodingPayload>) { export async function processStudioTranscoding (options: ProcessOptions<RunnerJobStudioTranscodingPayload>) {
const { server, job, runnerToken } = options const { server, job, runnerToken } = options
const payload = job.payload const payload = job.payload
let inputPath: string let videoInputPath: string
let separatedAudioInputPath: string
let tmpVideoInputFilePath: string
let tmpSeparatedAudioInputFilePath: string
let outputPath: string let outputPath: string
let tmpInputFilePath: string
let tasksProgress = 0 let tasksProgress = 0
@ -36,8 +47,11 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
try { try {
logger.info(`Downloading input file ${payload.input.videoFileUrl} for job ${job.jobToken}`) logger.info(`Downloading input file ${payload.input.videoFileUrl} for job ${job.jobToken}`)
inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) videoInputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
tmpInputFilePath = inputPath separatedAudioInputPath = await downloadSeparatedAudioFileIfNeeded({ urls: payload.input.separatedAudioFileUrl, runnerToken, job })
tmpVideoInputFilePath = videoInputPath
tmpSeparatedAudioInputFilePath = separatedAudioInputPath
logger.info(`Input file ${payload.input.videoFileUrl} downloaded for job ${job.jobToken}. Running studio transcoding tasks.`) logger.info(`Input file ${payload.input.videoFileUrl} downloaded for job ${job.jobToken}. Running studio transcoding tasks.`)
@ -46,17 +60,20 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename) outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename)
await processTask({ await processTask({
inputPath: tmpInputFilePath, videoInputPath: tmpVideoInputFilePath,
separatedAudioInputPath: tmpSeparatedAudioInputFilePath,
outputPath, outputPath,
task, task,
job, job,
runnerToken runnerToken
}) })
if (tmpInputFilePath) await remove(tmpInputFilePath) if (tmpVideoInputFilePath) await remove(tmpVideoInputFilePath)
if (tmpSeparatedAudioInputFilePath) await remove(tmpSeparatedAudioInputFilePath)
// For the next iteration // For the next iteration
tmpInputFilePath = outputPath tmpVideoInputFilePath = outputPath
tmpSeparatedAudioInputFilePath = undefined
tasksProgress += Math.floor(100 / payload.tasks.length) tasksProgress += Math.floor(100 / payload.tasks.length)
} }
@ -72,7 +89,8 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
payload: successBody payload: successBody
}) })
} finally { } finally {
if (tmpInputFilePath) await remove(tmpInputFilePath) if (tmpVideoInputFilePath) await remove(tmpVideoInputFilePath)
if (tmpSeparatedAudioInputFilePath) await remove(tmpSeparatedAudioInputFilePath)
if (outputPath) await remove(outputPath) if (outputPath) await remove(outputPath)
if (updateProgressInterval) clearInterval(updateProgressInterval) if (updateProgressInterval) clearInterval(updateProgressInterval)
} }
@ -83,8 +101,11 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = { type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
inputPath: string videoInputPath: string
separatedAudioInputPath: string
outputPath: string outputPath: string
task: T task: T
runnerToken: string runnerToken: string
job: JobWithToken job: JobWithToken
@ -107,15 +128,15 @@ async function processTask (options: TaskProcessorOptions) {
} }
async function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) { async function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) {
const { inputPath, task, runnerToken, job } = options const { videoInputPath, task, runnerToken, job } = options
logger.debug('Adding intro/outro to ' + inputPath) logger.debug(`Adding intro/outro to ${videoInputPath}`)
const introOutroPath = await downloadInputFile({ url: task.options.file, runnerToken, job }) const introOutroPath = await downloadInputFile({ url: task.options.file, runnerToken, job })
try { try {
await buildFFmpegEdition().addIntroOutro({ await buildFFmpegEdition().addIntroOutro({
...pick(options, [ 'inputPath', 'outputPath' ]), ...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
introOutroPath, introOutroPath,
type: task.name === 'add-intro' type: task.name === 'add-intro'
@ -128,12 +149,12 @@ async function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTa
} }
function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) { function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
const { inputPath, task } = options const { videoInputPath, task } = options
logger.debug(`Cutting ${inputPath}`) logger.debug(`Cutting ${videoInputPath}`)
return buildFFmpegEdition().cutVideo({ return buildFFmpegEdition().cutVideo({
...pick(options, [ 'inputPath', 'outputPath' ]), ...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
start: task.options.start, start: task.options.start,
end: task.options.end end: task.options.end
@ -141,15 +162,15 @@ function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
} }
async function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) { async function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) {
const { inputPath, task, runnerToken, job } = options const { videoInputPath, task, runnerToken, job } = options
logger.debug('Adding watermark to ' + inputPath) logger.debug(`Adding watermark to ${videoInputPath}`)
const watermarkPath = await downloadInputFile({ url: task.options.file, runnerToken, job }) const watermarkPath = await downloadInputFile({ url: task.options.file, runnerToken, job })
try { try {
await buildFFmpegEdition().addWatermark({ await buildFFmpegEdition().addWatermark({
...pick(options, [ 'inputPath', 'outputPath' ]), ...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
watermarkPath, watermarkPath,

View File

@ -1,5 +1,3 @@
import { remove } from 'fs-extra/esm'
import { join } from 'path'
import { import {
RunnerJobVODAudioMergeTranscodingPayload, RunnerJobVODAudioMergeTranscodingPayload,
RunnerJobVODHLSTranscodingPayload, RunnerJobVODHLSTranscodingPayload,
@ -9,9 +7,17 @@ import {
VODWebVideoTranscodingSuccess VODWebVideoTranscodingSuccess
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils' import { buildUUID } from '@peertube/peertube-node-utils'
import { remove } from 'fs-extra/esm'
import { join } from 'path'
import { ConfigManager } from '../../../shared/config-manager.js' import { ConfigManager } from '../../../shared/config-manager.js'
import { logger } from '../../../shared/index.js' import { logger } from '../../../shared/index.js'
import { buildFFmpegVOD, downloadInputFile, ProcessOptions, scheduleTranscodingProgress } from './common.js' import {
buildFFmpegVOD,
downloadInputFile,
downloadSeparatedAudioFileIfNeeded,
ProcessOptions,
scheduleTranscodingProgress
} from './common.js'
export async function processWebVideoTranscoding (options: ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>) { export async function processWebVideoTranscoding (options: ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>) {
const { server, job, runnerToken } = options const { server, job, runnerToken } = options
@ -19,7 +25,8 @@ export async function processWebVideoTranscoding (options: ProcessOptions<Runner
const payload = job.payload const payload = job.payload
let ffmpegProgress: number let ffmpegProgress: number
let inputPath: string let videoInputPath: string
let separatedAudioInputPath: string
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`) const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
@ -33,7 +40,8 @@ export async function processWebVideoTranscoding (options: ProcessOptions<Runner
try { try {
logger.info(`Downloading input file ${payload.input.videoFileUrl} for web video transcoding job ${job.jobToken}`) logger.info(`Downloading input file ${payload.input.videoFileUrl} for web video transcoding job ${job.jobToken}`)
inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) videoInputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
separatedAudioInputPath = await downloadSeparatedAudioFileIfNeeded({ urls: payload.input.separatedAudioFileUrl, runnerToken, job })
logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running web video transcoding.`) logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running web video transcoding.`)
@ -44,7 +52,8 @@ export async function processWebVideoTranscoding (options: ProcessOptions<Runner
await ffmpegVod.transcode({ await ffmpegVod.transcode({
type: 'video', type: 'video',
inputPath, videoInputPath,
separatedAudioInputPath,
outputPath, outputPath,
@ -65,7 +74,8 @@ export async function processWebVideoTranscoding (options: ProcessOptions<Runner
payload: successBody payload: successBody
}) })
} finally { } finally {
if (inputPath) await remove(inputPath) if (videoInputPath) await remove(videoInputPath)
if (separatedAudioInputPath) await remove(separatedAudioInputPath)
if (outputPath) await remove(outputPath) if (outputPath) await remove(outputPath)
if (updateProgressInterval) clearInterval(updateProgressInterval) if (updateProgressInterval) clearInterval(updateProgressInterval)
} }
@ -76,7 +86,8 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
const payload = job.payload const payload = job.payload
let ffmpegProgress: number let ffmpegProgress: number
let inputPath: string let videoInputPath: string
let separatedAudioInputPath: string
const uuid = buildUUID() const uuid = buildUUID()
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `${uuid}-${payload.output.resolution}.m3u8`) const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `${uuid}-${payload.output.resolution}.m3u8`)
@ -93,7 +104,8 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
try { try {
logger.info(`Downloading input file ${payload.input.videoFileUrl} for HLS transcoding job ${job.jobToken}`) logger.info(`Downloading input file ${payload.input.videoFileUrl} for HLS transcoding job ${job.jobToken}`)
inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) videoInputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
separatedAudioInputPath = await downloadSeparatedAudioFileIfNeeded({ urls: payload.input.separatedAudioFileUrl, runnerToken, job })
logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running HLS transcoding.`) logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running HLS transcoding.`)
@ -104,14 +116,18 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
await ffmpegVod.transcode({ await ffmpegVod.transcode({
type: 'hls', type: 'hls',
copyCodecs: false, copyCodecs: false,
inputPath,
videoInputPath,
separatedAudioInputPath,
hlsPlaylist: { videoFilename }, hlsPlaylist: { videoFilename },
outputPath, outputPath,
inputFileMutexReleaser: () => {}, inputFileMutexReleaser: () => {},
resolution: payload.output.resolution, resolution: payload.output.resolution,
fps: payload.output.fps fps: payload.output.fps,
separatedAudio: payload.output.separatedAudio
}) })
const successBody: VODHLSTranscodingSuccess = { const successBody: VODHLSTranscodingSuccess = {
@ -126,7 +142,8 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
payload: successBody payload: successBody
}) })
} finally { } finally {
if (inputPath) await remove(inputPath) if (videoInputPath) await remove(videoInputPath)
if (separatedAudioInputPath) await remove(separatedAudioInputPath)
if (outputPath) await remove(outputPath) if (outputPath) await remove(outputPath)
if (videoPath) await remove(videoPath) if (videoPath) await remove(videoPath)
if (updateProgressInterval) clearInterval(updateProgressInterval) if (updateProgressInterval) clearInterval(updateProgressInterval)
@ -139,7 +156,7 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
let ffmpegProgress: number let ffmpegProgress: number
let audioPath: string let audioPath: string
let inputPath: string let previewPath: string
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`) const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
@ -157,7 +174,7 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
) )
audioPath = await downloadInputFile({ url: payload.input.audioFileUrl, runnerToken, job }) audioPath = await downloadInputFile({ url: payload.input.audioFileUrl, runnerToken, job })
inputPath = await downloadInputFile({ url: payload.input.previewFileUrl, runnerToken, job }) previewPath = await downloadInputFile({ url: payload.input.previewFileUrl, runnerToken, job })
logger.info( logger.info(
`Downloaded input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` + `Downloaded input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` +
@ -172,7 +189,7 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
type: 'merge-audio', type: 'merge-audio',
audioPath, audioPath,
inputPath, videoInputPath: previewPath,
outputPath, outputPath,
@ -194,7 +211,7 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
}) })
} finally { } finally {
if (audioPath) await remove(audioPath) if (audioPath) await remove(audioPath)
if (inputPath) await remove(inputPath) if (previewPath) await remove(previewPath)
if (outputPath) await remove(outputPath) if (outputPath) await remove(outputPath)
if (updateProgressInterval) clearInterval(updateProgressInterval) if (updateProgressInterval) clearInterval(updateProgressInterval)
} }

View File

@ -11,12 +11,13 @@ export type ResolutionOption = {
@Injectable() @Injectable()
export class EditConfigurationService { export class EditConfigurationService {
getVODResolutions () { getTranscodingResolutions () {
return [ return [
{ {
id: '0p', id: '0p',
label: $localize`Audio-only`, label: $localize`Audio-only`,
description: $localize`A <code>.mp4</code> that keeps the original audio track, with no video` // eslint-disable-next-line max-len
description: $localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users`
}, },
{ {
id: '144p', id: '144p',
@ -53,14 +54,14 @@ export class EditConfigurationService {
] ]
} }
getLiveResolutions () {
return this.getVODResolutions().filter(r => r.id !== '0p')
}
isTranscodingEnabled (form: FormGroup) { isTranscodingEnabled (form: FormGroup) {
return form.value['transcoding']['enabled'] === true return form.value['transcoding']['enabled'] === true
} }
isHLSEnabled (form: FormGroup) {
return form.value['transcoding']['hls']['enabled'] === true
}
isRemoteRunnerVODEnabled (form: FormGroup) { isRemoteRunnerVODEnabled (form: FormGroup) {
return form.value['transcoding']['remoteRunners']['enabled'] === true return form.value['transcoding']['remoteRunners']['enabled'] === true
} }

View File

@ -152,3 +152,8 @@ my-actor-banner-edit {
max-width: $form-max-width; max-width: $form-max-width;
} }
h4 {
font-weight: $font-bold;
margin-bottom: 0.5rem;
font-size: 1rem;
}

View File

@ -1,7 +1,6 @@
import omit from 'lodash-es/omit' import { NgFor, NgIf } from '@angular/common'
import { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, OnInit } from '@angular/core' import { Component, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service' import { ConfigService } from '@app/+admin/config/shared/config.service'
import { Notifier } from '@app/core' import { Notifier } from '@app/core'
@ -28,18 +27,19 @@ import {
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators' import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive' import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models' import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models'
import { EditConfigurationService } from './edit-configuration.service' import omit from 'lodash-es/omit'
import { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component' import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component'
import { EditBasicConfigurationComponent } from './edit-basic-configuration.component'
import { EditConfigurationService } from './edit-configuration.service'
import { EditHomepageComponent } from './edit-homepage.component'
import { EditInstanceInformationComponent } from './edit-instance-information.component'
import { EditLiveConfigurationComponent } from './edit-live-configuration.component' import { EditLiveConfigurationComponent } from './edit-live-configuration.component'
import { EditVODTranscodingComponent } from './edit-vod-transcoding.component' import { EditVODTranscodingComponent } from './edit-vod-transcoding.component'
import { EditBasicConfigurationComponent } from './edit-basic-configuration.component'
import { EditInstanceInformationComponent } from './edit-instance-information.component'
import { EditHomepageComponent } from './edit-homepage.component'
import { NgbNav, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgIf, NgFor } from '@angular/common'
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
type ComponentCustomConfig = CustomConfig & { type ComponentCustomConfig = CustomConfig & {
instanceCustomHomepage: CustomPage instanceCustomHomepage: CustomPage
@ -230,7 +230,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
keep: null keep: null
}, },
hls: { hls: {
enabled: null enabled: null,
splitAudioAndVideo: null
}, },
webVideos: { webVideos: {
enabled: null enabled: null
@ -341,12 +342,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
} }
} }
for (const resolution of this.editConfigurationService.getVODResolutions()) { for (const resolution of this.editConfigurationService.getTranscodingResolutions()) {
defaultValues.transcoding.resolutions[resolution.id] = 'false' defaultValues.transcoding.resolutions[resolution.id] = 'false'
formGroupData.transcoding.resolutions[resolution.id] = null formGroupData.transcoding.resolutions[resolution.id] = null
}
for (const resolution of this.editConfigurationService.getLiveResolutions()) {
defaultValues.live.transcoding.resolutions[resolution.id] = 'false' defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
formGroupData.live.transcoding.resolutions[resolution.id] = null formGroupData.live.transcoding.resolutions[resolution.id] = null
} }

View File

@ -114,33 +114,36 @@
<div class="callout callout-light pt-2 mt-2 pb-0"> <div class="callout callout-light pt-2 mt-2 pb-0">
<h3 class="callout-title" i18n>Output formats</h3> <h3 class="callout-title" i18n>Output formats</h3>
<div class="form-group" [ngClass]="getDisabledLiveTranscodingClass()"> <div [ngClass]="getDisabledLiveTranscodingClass()">
<label i18n for="liveTranscodingThreads">Live resolutions to generate</label>
<div class="ms-2 mt-2 d-flex flex-column"> <div class="ms-2 mt-3">
<h4 i18n>Live resolutions to generate</h4>
<ng-container formGroupName="resolutions"> <div class="mt-3">
<div class="form-group" *ngFor="let resolution of liveResolutions">
<ng-container formGroupName="resolutions">
<div class="form-group" *ngFor="let resolution of liveResolutions">
<my-peertube-checkbox
[inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
labelText="{{resolution.label}}"
>
<ng-template *ngIf="resolution.description" ptTemplate="help">
<div [innerHTML]="resolution.description"></div>
</ng-template>
</my-peertube-checkbox>
</div>
</ng-container>
<div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
[inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id" inputName="liveTranscodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
labelText="{{resolution.label}}" i18n-labelText labelText="Also transcode original resolution"
> >
<ng-template *ngIf="resolution.description" ptTemplate="help"> <ng-container i18n ngProjectAs="description">
<div [innerHTML]="resolution.description"></div> Even if it's above your maximum enabled resolution
</ng-template> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container>
<div class="form-group">
<my-peertube-checkbox
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
i18n-labelText labelText="Also transcode original resolution"
>
<ng-container i18n ngProjectAs="description">
Even if it's above your maximum enabled resolution
</ng-container>
</my-peertube-checkbox>
</div> </div>
</div> </div>
</div> </div>
@ -148,7 +151,7 @@
<div class="form-group mt-4" formGroupName="remoteRunners" [ngClass]="getDisabledLiveTranscodingClass()"> <div class="form-group mt-4" formGroupName="remoteRunners" [ngClass]="getDisabledLiveTranscodingClass()">
<my-peertube-checkbox <my-peertube-checkbox
inputName="transcodingRemoteRunnersEnabled" formControlName="enabled" inputName="liveTranscodingRemoteRunnersEnabled" formControlName="enabled"
i18n-labelText labelText="Enable remote runners for lives" i18n-labelText labelText="Enable remote runners for lives"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">

View File

@ -56,7 +56,7 @@ export class EditLiveConfigurationComponent implements OnInit, OnChanges {
{ id: 1000 * 3600 * 10, label: $localize`10 hours` } { id: 1000 * 3600 * 10, label: $localize`10 hours` }
] ]
this.liveResolutions = this.editConfigurationService.getLiveResolutions() this.liveResolutions = this.editConfigurationService.getTranscodingResolutions()
} }
ngOnChanges (changes: SimpleChanges) { ngOnChanges (changes: SimpleChanges) {

View File

@ -115,7 +115,25 @@
<p>If you also enabled Web Videos support, it will multiply videos storage by 2</p> <p>If you also enabled Web Videos support, it will multiply videos storage by 2</p>
</ng-container> </ng-container>
</ng-template> </ng-template>
<ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="getHLSDisabledClass()">
<my-peertube-checkbox
inputName="transcodingHlsSplitAudioAndVideo" formControlName="splitAudioAndVideo"
i18n-labelText labelText="Split audio and video streams"
>
<ng-template ptTemplate="help">
<ng-container i18n>Store the audio stream in a separate file from the video.</ng-container> <br />
<ng-container i18n>This option adds the ability for the HLS player to propose the "Audio only" quality to users.</ng-container> <br />
<ng-container i18n>It also saves disk space by not duplicating the audio stream in each resolution file</ng-container>
</ng-template>
</my-peertube-checkbox>
</div>
</ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
@ -123,16 +141,6 @@
<div class="mb-2 fw-bold" i18n>Resolutions to generate</div> <div class="mb-2 fw-bold" i18n>Resolutions to generate</div>
<div class="ms-2 d-flex flex-column"> <div class="ms-2 d-flex flex-column">
<my-peertube-checkbox
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
i18n-labelText labelText="Always transcode original resolution"
>
</my-peertube-checkbox>
<span class="mt-3 mb-2 small muted" i18n>
The original file resolution will be the default target if no option is selected.
</span>
<ng-container formGroupName="resolutions"> <ng-container formGroupName="resolutions">
<div class="form-group" *ngFor="let resolution of resolutions"> <div class="form-group" *ngFor="let resolution of resolutions">
<my-peertube-checkbox <my-peertube-checkbox
@ -145,6 +153,15 @@
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
<my-peertube-checkbox
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
i18n-labelText labelText="Also transcode original resolution"
>
<ng-container i18n ngProjectAs="description">
Even if it's above your maximum enabled resolution
</ng-container>
</my-peertube-checkbox>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,15 +1,16 @@
import { SelectOptionsItem } from 'src/types/select-options-item.model' import { NgClass, NgFor, NgIf } from '@angular/common'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { Notifier } from '@app/core'
import { HTMLServerConfig } from '@peertube/peertube-models' import { HTMLServerConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
import { ConfigService } from '../shared/config.service' import { ConfigService } from '../shared/config.service'
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service' import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { RouterLink } from '@angular/router'
import { NgClass, NgFor, NgIf } from '@angular/common'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
@Component({ @Component({
selector: 'my-edit-vod-transcoding', selector: 'my-edit-vod-transcoding',
@ -42,12 +43,13 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
constructor ( constructor (
private configService: ConfigService, private configService: ConfigService,
private editConfigurationService: EditConfigurationService private editConfigurationService: EditConfigurationService,
private notifier: Notifier
) { } ) { }
ngOnInit () { ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.editConfigurationService.getVODResolutions() this.resolutions = this.editConfigurationService.getTranscodingResolutions()
this.checkTranscodingFields() this.checkTranscodingFields()
} }
@ -84,6 +86,10 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
return this.editConfigurationService.isTranscodingEnabled(this.form) return this.editConfigurationService.isTranscodingEnabled(this.form)
} }
isHLSEnabled () {
return this.editConfigurationService.isHLSEnabled(this.form)
}
isStudioEnabled () { isStudioEnabled () {
return this.editConfigurationService.isStudioEnabled(this.form) return this.editConfigurationService.isStudioEnabled(this.form)
} }
@ -92,6 +98,10 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() } return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
} }
getHLSDisabledClass () {
return { 'disabled-checkbox-extra': !this.isHLSEnabled() }
}
getLocalTranscodingDisabledClass () { getLocalTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() } return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
} }
@ -111,33 +121,31 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
const webVideosControl = this.form.get('transcoding.webVideos.enabled') const webVideosControl = this.form.get('transcoding.webVideos.enabled')
webVideosControl.valueChanges webVideosControl.valueChanges
.subscribe(newValue => { .subscribe(newValue => {
if (newValue === false && !hlsControl.disabled) { if (newValue === false && hlsControl.value === false) {
hlsControl.disable() hlsControl.setValue(true)
}
if (newValue === true && !hlsControl.enabled) { // eslint-disable-next-line max-len
hlsControl.enable() this.notifier.info($localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000)
} }
}) })
hlsControl.valueChanges hlsControl.valueChanges
.subscribe(newValue => { .subscribe(newValue => {
if (newValue === false && !webVideosControl.disabled) { if (newValue === false && webVideosControl.value === false) {
webVideosControl.disable() webVideosControl.setValue(true)
}
if (newValue === true && !webVideosControl.enabled) { // eslint-disable-next-line max-len
webVideosControl.enable() this.notifier.info($localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000)
} }
}) })
transcodingControl.valueChanges transcodingControl.valueChanges
.subscribe(newValue => { .subscribe(newValue => {
if (newValue === false) { if (newValue === false) {
videoStudioControl.setValue(false) videoStudioControl.setValue(false)
} }
}) })
transcodingControl.updateValueAndValidity() transcodingControl.updateValueAndValidity()
webVideosControl.updateValueAndValidity() webVideosControl.updateValueAndValidity()

View File

@ -13,7 +13,7 @@ import { VideoRateComponent } from './video-rate.component'
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { VideoShareComponent } from '@app/shared/shared-share-modal/video-share.component' import { VideoShareComponent } from '@app/shared/shared-share-modal/video-share.component'
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component' import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
import { VideoDownloadComponent } from '@app/shared/shared-video-miniature/video-download.component' import { VideoDownloadComponent } from '@app/shared/shared-video-miniature/download/video-download.component'
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model' import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
@Component({ @Component({

View File

@ -65,13 +65,4 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
hasHlsPlaylist () { hasHlsPlaylist () {
return !!this.getHlsPlaylist() return !!this.getHlsPlaylist()
} }
getFiles () {
if (this.files.length !== 0) return this.files
const hls = this.getHlsPlaylist()
if (hls) return hls.files
return []
}
} }

View File

@ -16,6 +16,7 @@ import {
VideoChannel as VideoChannelServerModel, VideoChannel as VideoChannelServerModel,
VideoConstant, VideoConstant,
VideoDetails as VideoDetailsServerModel, VideoDetails as VideoDetailsServerModel,
VideoFile,
VideoFileMetadata, VideoFileMetadata,
VideoIncludeType, VideoIncludeType,
VideoPrivacy, VideoPrivacy,
@ -54,6 +55,7 @@ export type CommonVideoParams = {
@Injectable() @Injectable()
export class VideoService { export class VideoService {
static BASE_VIDEO_DOWNLOAD_URL = environment.originServerUrl + '/download/videos/generate'
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos' static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml' static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml'
@ -388,6 +390,22 @@ export class VideoService {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
generateDownloadUrl (options: {
video: Video
files: VideoFile[]
}) {
const { video, files } = options
if (files.length === 0) throw new Error('Cannot generate download URL without files')
let url = `${VideoService.BASE_VIDEO_DOWNLOAD_URL}/${video.uuid}?`
url += files.map(f => 'videoFileIds=' + f.id).join('&')
return url
}
// ---------------------------------------------------------------------------
getStoryboards (videoId: string | number, videoPassword: string) { getStoryboards (videoId: string | number, videoPassword: string) {
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword) const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)

View File

@ -179,17 +179,17 @@ export class UserSubscriptionService {
} }
doesSubscriptionExist (nameWithHost: string) { doesSubscriptionExist (nameWithHost: string) {
debugLogger('Running subscription check for %d.', nameWithHost) debugLogger('Running subscription check for ' + nameWithHost)
if (nameWithHost in this.myAccountSubscriptionCache) { if (nameWithHost in this.myAccountSubscriptionCache) {
debugLogger('Found cache for %d.', nameWithHost) debugLogger('Found cache for ' + nameWithHost)
return of(this.myAccountSubscriptionCache[nameWithHost]) return of(this.myAccountSubscriptionCache[nameWithHost])
} }
this.existsSubject.next(nameWithHost) this.existsSubject.next(nameWithHost)
debugLogger('Fetching from network for %d.', nameWithHost) debugLogger('Fetching from network for ' + nameWithHost)
return this.existsObservable.pipe( return this.existsObservable.pipe(
filter(existsResult => existsResult[nameWithHost] !== undefined), filter(existsResult => existsResult[nameWithHost] !== undefined),
map(existsResult => existsResult[nameWithHost]), map(existsResult => existsResult[nameWithHost]),

View File

@ -0,0 +1,24 @@
<ul ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeNavId">
<li *ngFor="let caption of getCaptions()" [ngbNavItem]="caption.language.id">
<button ngbNavLink>
{{ caption.language.label }}
<ng-container *ngIf="caption.automaticallyGenerated" i18n>(auto-generated)</ng-container>
</button>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text [show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getCaptionLink()"></my-input-text>
</div>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-2"></div>
<div class="modal-footer inputs">
<ng-content select="cancel-button"></ng-content>
<input type="submit" i18n-value value="Download" class="peertube-button orange-button" (click)="download()" />
</div>

View File

@ -0,0 +1,71 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { VideoCaption } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { InputTextComponent } from '../../shared-forms/input-text.component'
@Component({
selector: 'my-subtitle-files-download',
templateUrl: './subtitle-files-download.component.html',
standalone: true,
imports: [
NgIf,
NgFor,
InputTextComponent,
NgbNav,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
NgbNavContent,
NgbNavOutlet
]
})
export class SubtitleFilesDownloadComponent implements OnInit {
@Input({ required: true }) videoCaptions: VideoCaption[]
@Output() downloaded = new EventEmitter<void>()
activeNavId: string
getCaptions () {
if (!this.videoCaptions) return []
return this.videoCaptions
}
ngOnInit () {
if (this.hasCaptions()) {
this.activeNavId = this.videoCaptions[0].language.id
}
}
download () {
window.location.assign(this.getCaptionLink())
this.downloaded.emit()
}
hasCaptions () {
return this.getCaptions().length !== 0
}
getCaption () {
const caption = this.getCaptions()
.find(c => c.language.id === this.activeNavId)
if (!caption) {
logger.error(`Cannot find caption ${this.activeNavId}`)
return undefined
}
return caption
}
getCaptionLink () {
const caption = this.getCaption()
if (!caption) return ''
return window.location.origin + caption.captionPath
}
}

View File

@ -0,0 +1,54 @@
<ng-template #modal let-hide="close">
<div class="modal-header">
<h4 class="modal-title">
<ng-container i18n>Download</ng-container>
<div class="peertube-select-container title-select">
<select id="type" name="type" [(ngModel)]="type" class="form-control">
<option value="video-generate" i18n>Video</option>
<option value="video-files" i18n>Video files</option>
<option *ngIf="hasCaptions()" value="subtitle-files" i18n>Subtitle files</option>
</select>
</div>
</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body" [ngClass]="{ 'opacity-0': !loaded }">
<ng-template #cancelBlock>
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="hide()" (key.enter)="hide()"
>
</ng-template>
@switch (type) {
@case ('video-generate') {
<my-video-generate-download [video]="video" [originalVideoFile]="originalVideoFile" [videoFileToken]="videoFileToken" (downloaded)="onDownloaded()">
<ng-container ngProjectAs="cancel-button">
<ng-template [ngTemplateOutlet]="cancelBlock"></ng-template>
</ng-container>
</my-video-generate-download>
}
@case ('video-files') {
<my-video-files-download [video]="video" [originalVideoFile]="originalVideoFile" [videoFileToken]="videoFileToken" (downloaded)="onDownloaded()">
<ng-container ngProjectAs="cancel-button">
<ng-template [ngTemplateOutlet]="cancelBlock"></ng-template>
</ng-container>
</my-video-files-download>
}
@case ('subtitle-files') {
<my-subtitle-files-download [videoCaptions]="getCaptions()" (downloaded)="onDownloaded()">
<ng-container ngProjectAs="cancel-button">
<ng-template [ngTemplateOutlet]="cancelBlock"></ng-template>
</ng-container>
</my-subtitle-files-download>
}
}
</div>
</ng-template>

View File

@ -0,0 +1,40 @@
@use '_variables' as *;
@use '_mixins' as *;
.modal-body ::ng-deep {
.nav-content {
margin-top: 30px;
}
my-global-icon[iconName=shield] {
@include margin-left(10px);
width: 16px;
position: relative;
top: -2px;
}
.modal-footer {
padding-inline-end: 0;
margin-top: 1rem;
> *:last-child {
margin-inline-end: 0;
}
}
}
.peertube-select-container.title-select {
@include peertube-select-container(auto);
display: inline-block;
margin-left: 10px;
vertical-align: top;
}
#dropdown-download-type {
cursor: pointer;
}

View File

@ -0,0 +1,123 @@
import { NgClass, NgIf, NgTemplateOutlet } from '@angular/common'
import { Component, ElementRef, Input, ViewChild } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { AuthService, HooksService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { VideoCaption, VideoSource } from '@peertube/peertube-models'
import { videoRequiresFileToken } from '@root-helpers/video'
import { of } from 'rxjs'
import { catchError } from 'rxjs/operators'
import { VideoDetails } from '../../shared-main/video/video-details.model'
import { VideoFileTokenService } from '../../shared-main/video/video-file-token.service'
import { VideoService } from '../../shared-main/video/video.service'
import { SubtitleFilesDownloadComponent } from './subtitle-files-download.component'
import { VideoFilesDownloadComponent } from './video-files-download.component'
import { VideoGenerateDownloadComponent } from './video-generate-download.component'
type DownloadType = 'video-generate' | 'video-files' | 'subtitle-files'
@Component({
selector: 'my-video-download',
templateUrl: './video-download.component.html',
styleUrls: [ './video-download.component.scss' ],
standalone: true,
imports: [
SubtitleFilesDownloadComponent,
VideoFilesDownloadComponent,
VideoGenerateDownloadComponent,
GlobalIconComponent,
NgIf,
FormsModule,
NgClass,
NgTemplateOutlet
]
})
export class VideoDownloadComponent {
@ViewChild('modal', { static: true }) modal: ElementRef
@Input() videoPassword: string
video: VideoDetails
type: DownloadType = 'video-generate'
videoFileToken: string
originalVideoFile: VideoSource
loaded = false
private videoCaptions: VideoCaption[]
private activeModal: NgbModalRef
constructor (
private modalService: NgbModal,
private authService: AuthService,
private videoService: VideoService,
private videoFileTokenService: VideoFileTokenService,
private hooks: HooksService
) {}
getCaptions () {
if (!this.videoCaptions) return []
return this.videoCaptions
}
show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
this.loaded = false
this.videoFileToken = undefined
this.originalVideoFile = undefined
this.video = video
this.videoCaptions = videoCaptions
this.activeModal = this.modalService.open(this.modal, { centered: true })
this.getOriginalVideoFileObs()
.subscribe(source => {
if (source?.fileDownloadUrl) {
this.originalVideoFile = source
}
if (this.originalVideoFile || videoRequiresFileToken(this.video)) {
this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
.subscribe(({ token }) => {
this.videoFileToken = token
this.loaded = true
})
} else {
this.loaded = true
}
})
this.activeModal.shown.subscribe(() => {
this.hooks.runAction('action:modal.video-download.shown', 'common')
})
}
private getOriginalVideoFileObs () {
if (!this.video.isLocal || !this.authService.isLoggedIn()) return of(undefined)
const user = this.authService.getUser()
if (!this.video.isOwnerOrHasSeeAllVideosRight(user)) return of(undefined)
return this.videoService.getSource(this.video.id)
.pipe(catchError(err => {
console.error('Cannot get source file', err)
return of(undefined)
}))
}
// ---------------------------------------------------------------------------
onDownloaded () {
this.activeModal.close()
}
hasCaptions () {
return this.getCaptions().length !== 0
}
}

View File

@ -0,0 +1,123 @@
<div class="alert alert-warning" *ngIf="isConfidentialVideo()" i18n>
The following link contains a private token and should not be shared with anyone.
</div>
<div ngbNav #resolutionNav="ngbNav" class="nav-tabs" [activeId]="activeResolutionId" (activeIdChange)="onResolutionIdChange($event)">
<ng-template #rootNavContent>
<div class="nav-content">
<my-input-text [show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getVideoFileLink()"></my-input-text>
</div>
</ng-template>
<ng-container *ngIf="originalVideoFile" ngbNavItem="original">
<a ngbNavLink>
<ng-container i18n>Original file</ng-container>
<my-global-icon i18n-ngbTooltip ngbTooltip="Other users cannot download the original file" iconName="shield"></my-global-icon>
</a>
<ng-template ngbNavContent>
<ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
</ng-template>
</ng-container>
<ng-container *ngFor="let file of getVideoFiles()" [ngbNavItem]="file.resolution.id">
<a ngbNavLink>{{ file.resolution.label }}</a>
<ng-template ngbNavContent>
<ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="resolutionNav"></div>
<div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true">
<div ngbNav #navMetadata="ngbNav" class="nav-tabs nav-metadata">
<ng-template #metadataInfo let-item>
<div class="metadata-attribute">
<span>{{ item.value.label }}</span>
@if (item.value.value) {
<span>{{ item.value.value }}</span>
} @else {
<span i18n>Unknown</span>
}
</div>
</ng-template>
<ng-container ngbNavItem>
<a ngbNavLink i18n>Format</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataFormat | keyvalue; track item.key) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem *ngIf="videoFileMetadataVideoStream !== undefined">
<a ngbNavLink i18n>Video stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataVideoStream | keyvalue; track item.key) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem *ngIf="videoFileMetadataAudioStream !== undefined">
<a ngbNavLink i18n>Audio stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataAudioStream | keyvalue; track item.key) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
</div>
<div *ngIf="hasMetadata()" [ngbNavOutlet]="navMetadata"></div>
<div [hidden]="originalVideoFile || !getVideoFile()?.torrentDownloadUrl" class="download-type">
<div class="peertube-radio-container">
<input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
<label i18n for="download-direct">Direct download</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
<label i18n for="download-torrent">Torrent (.torrent file)</label>
</div>
</div>
</div>
<button
(click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed"
class="advanced-filters-button button-unstyle"
[attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic"
>
@if (isAdvancedCustomizationCollapsed) {
<span class="chevron-down"></span>
<ng-container i18n>More information/options</ng-container>
} @else {
<span class="chevron-up"></span>
<ng-container i18n>Less information/options</ng-container>
}
</button>
<div class="modal-footer inputs">
<ng-content select="cancel-button"></ng-content>
<input type="submit" i18n-value value="Download" class="peertube-button orange-button" (click)="download()" />
</div>

View File

@ -1,17 +1,6 @@
@use '_variables' as *; @use '_variables' as *;
@use '_mixins' as *; @use '_mixins' as *;
.nav-content {
margin-top: 30px;
}
my-global-icon[iconName=shield] {
@include margin-left(10px);
width: 16px;
margin-top: -3px;
}
.advanced-filters-button { .advanced-filters-button {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -25,28 +14,6 @@ my-global-icon[iconName=shield] {
} }
} }
.peertube-select-container.title-select {
@include peertube-select-container(auto);
display: inline-block;
margin-left: 10px;
vertical-align: top;
}
#dropdown-download-type {
cursor: pointer;
}
.download-type {
margin-top: 20px;
.peertube-radio-container {
@include margin-right(30px);
display: inline-block;
}
}
.nav-metadata { .nav-metadata {
margin-top: 20px; margin-top: 20px;
} }
@ -69,3 +36,13 @@ my-global-icon[iconName=shield] {
font-weight: $font-bold; font-weight: $font-bold;
} }
} }
.download-type {
margin-top: 20px;
.peertube-radio-container {
@include margin-right(30px);
display: inline-block;
}
}

View File

@ -1,11 +1,8 @@
import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common' import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core' import { Component, EventEmitter, Inject, Input, LOCALE_ID, OnInit, Output } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { AuthService, HooksService } from '@app/core'
import { import {
NgbCollapse, NgbCollapse,
NgbModal,
NgbModalRef,
NgbNav, NgbNav,
NgbNavContent, NgbNavContent,
NgbNavItem, NgbNavItem,
@ -15,34 +12,32 @@ import {
NgbTooltip NgbTooltip
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { objectKeysTyped, pick } from '@peertube/peertube-core-utils' import { objectKeysTyped, pick } from '@peertube/peertube-core-utils'
import { VideoCaption, VideoFile, VideoFileMetadata, VideoSource } from '@peertube/peertube-models' import { VideoFile, VideoFileMetadata, VideoSource } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger' import { logger } from '@root-helpers/logger'
import { videoRequiresFileToken } from '@root-helpers/video' import { videoRequiresFileToken } from '@root-helpers/video'
import { mapValues } from 'lodash-es' import { mapValues } from 'lodash-es'
import { firstValueFrom, of } from 'rxjs' import { firstValueFrom } from 'rxjs'
import { catchError, tap } from 'rxjs/operators' import { tap } from 'rxjs/operators'
import { InputTextComponent } from '../shared-forms/input-text.component' import { InputTextComponent } from '../../shared-forms/input-text.component'
import { GlobalIconComponent } from '../shared-icons/global-icon.component' import { GlobalIconComponent } from '../../shared-icons/global-icon.component'
import { BytesPipe } from '../shared-main/angular/bytes.pipe' import { BytesPipe } from '../../shared-main/angular/bytes.pipe'
import { NumberFormatterPipe } from '../shared-main/angular/number-formatter.pipe' import { NumberFormatterPipe } from '../../shared-main/angular/number-formatter.pipe'
import { VideoDetails } from '../shared-main/video/video-details.model' import { VideoDetails } from '../../shared-main/video/video-details.model'
import { VideoFileTokenService } from '../shared-main/video/video-file-token.service' import { VideoService } from '../../shared-main/video/video.service'
import { VideoService } from '../shared-main/video/video.service'
type DownloadType = 'video' | 'subtitles'
type FileMetadata = { [key: string]: { label: string, value: string | number } } type FileMetadata = { [key: string]: { label: string, value: string | number } }
@Component({ @Component({
selector: 'my-video-download', selector: 'my-video-files-download',
templateUrl: './video-download.component.html', templateUrl: './video-files-download.component.html',
styleUrls: [ './video-download.component.scss' ], styleUrls: [ './video-files-download.component.scss' ],
standalone: true, standalone: true,
imports: [ imports: [
NgIf, NgIf,
FormsModule, FormsModule,
GlobalIconComponent, GlobalIconComponent,
NgbNav,
NgFor, NgFor,
NgbNav,
NgbNavItem, NgbNavItem,
NgbNavLink, NgbNavLink,
NgbNavLinkBase, NgbNavLinkBase,
@ -56,15 +51,16 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
NgClass NgClass
] ]
}) })
export class VideoDownloadComponent { export class VideoFilesDownloadComponent implements OnInit {
@ViewChild('modal', { static: true }) modal: ElementRef @Input({ required: true }) video: VideoDetails
@Input() originalVideoFile: VideoSource
@Input() videoFileToken: string
@Input() videoPassword: string @Output() downloaded = new EventEmitter<void>()
downloadType: 'direct' | 'torrent' = 'direct' downloadType: 'direct' | 'torrent' = 'direct'
resolutionId: number | 'original' = -1 activeResolutionId: number | 'original' = -1
subtitleLanguageId: string
videoFileMetadataFormat: FileMetadata videoFileMetadataFormat: FileMetadata
videoFileMetadataVideoStream: FileMetadata | undefined videoFileMetadataVideoStream: FileMetadata | undefined
@ -72,133 +68,50 @@ export class VideoDownloadComponent {
isAdvancedCustomizationCollapsed = true isAdvancedCustomizationCollapsed = true
type: DownloadType = 'video'
videoFileToken: string
originalVideoFile: VideoSource
loaded = false
private activeModal: NgbModalRef
private bytesPipe: BytesPipe private bytesPipe: BytesPipe
private numbersPipe: NumberFormatterPipe private numbersPipe: NumberFormatterPipe
private video: VideoDetails
private videoCaptions: VideoCaption[]
constructor ( constructor (
@Inject(LOCALE_ID) private localeId: string, @Inject(LOCALE_ID) private localeId: string,
private modalService: NgbModal, private videoService: VideoService
private authService: AuthService,
private videoService: VideoService,
private videoFileTokenService: VideoFileTokenService,
private hooks: HooksService
) { ) {
this.bytesPipe = new BytesPipe() this.bytesPipe = new BytesPipe()
this.numbersPipe = new NumberFormatterPipe(this.localeId) this.numbersPipe = new NumberFormatterPipe(this.localeId)
} }
get typeText () { ngOnInit () {
return this.type === 'video'
? $localize`video`
: $localize`subtitles`
}
getVideoFiles () {
if (!this.video) return []
return this.video.getFiles()
}
getCaptions () {
if (!this.videoCaptions) return []
return this.videoCaptions
}
show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
this.loaded = false
this.videoFileToken = undefined
this.originalVideoFile = undefined
this.video = video
this.videoCaptions = videoCaptions
this.activeModal = this.modalService.open(this.modal, { centered: true })
if (this.hasFiles()) { if (this.hasFiles()) {
this.onResolutionIdChange(this.getVideoFiles()[0].resolution.id) this.onResolutionIdChange(this.getVideoFiles()[0].resolution.id)
} }
if (this.hasCaptions()) {
this.subtitleLanguageId = this.videoCaptions[0].language.id
}
this.getOriginalVideoFileObs()
.subscribe(source => {
if (source?.fileDownloadUrl) {
this.originalVideoFile = source
}
if (this.originalVideoFile || this.isConfidentialVideo()) {
this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
.subscribe(({ token }) => {
this.videoFileToken = token
this.loaded = true
})
} else {
this.loaded = true
}
})
this.activeModal.shown.subscribe(() => {
this.hooks.runAction('action:modal.video-download.shown', 'common')
})
} }
private getOriginalVideoFileObs () { getVideoFiles () {
if (!this.video.isLocal || !this.authService.isLoggedIn()) return of(undefined) if (!this.video) return []
if (this.video.files.length !== 0) return this.video.files
const user = this.authService.getUser() const hls = this.video.getHlsPlaylist()
if (!this.video.isOwnerOrHasSeeAllVideosRight(user)) return of(undefined) if (hls) return hls.files
return this.videoService.getSource(this.video.id) return []
.pipe(catchError(err => {
console.error('Cannot get source file', err)
return of(undefined)
}))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
onClose () {
this.video = undefined
this.videoCaptions = undefined
}
download () { download () {
window.location.assign(this.getLink()) window.location.assign(this.getVideoFileLink())
this.activeModal.close() this.downloaded.emit()
} }
getLink () { // ---------------------------------------------------------------------------
return this.type === 'subtitles' && this.videoCaptions
? this.getCaptionLink()
: this.getVideoFileLink()
}
async onResolutionIdChange (resolutionId: number | 'original') { async onResolutionIdChange (resolutionId: number | 'original') {
this.resolutionId = resolutionId this.activeResolutionId = resolutionId
let metadata: VideoFileMetadata let metadata: VideoFileMetadata
if (this.resolutionId === 'original') { if (this.activeResolutionId === 'original') {
metadata = this.originalVideoFile.metadata metadata = this.originalVideoFile.metadata
} else { } else {
const videoFile = this.getVideoFile() const videoFile = this.getVideoFile()
@ -218,22 +131,20 @@ export class VideoDownloadComponent {
this.videoFileMetadataAudioStream = this.getMetadataStream(metadata.streams, 'audio') this.videoFileMetadataAudioStream = this.getMetadataStream(metadata.streams, 'audio')
} }
onSubtitleIdChange (subtitleId: string) { // ---------------------------------------------------------------------------
this.subtitleLanguageId = subtitleId
}
hasFiles () { hasFiles () {
return this.getVideoFiles().length !== 0 return this.getVideoFiles().length !== 0
} }
getVideoFile () { getVideoFile () {
if (this.resolutionId === 'original') return undefined if (this.activeResolutionId === 'original') return undefined
const file = this.getVideoFiles() const file = this.getVideoFiles()
.find(f => f.resolution.id === this.resolutionId) .find(f => f.resolution.id === this.activeResolutionId)
if (!file) { if (!file) {
logger.error(`Could not find file with resolution ${this.resolutionId}`) logger.error(`Could not find file with resolution ${this.activeResolutionId}`)
return undefined return undefined
} }
@ -241,11 +152,11 @@ export class VideoDownloadComponent {
} }
getVideoFileLink () { getVideoFileLink () {
const suffix = this.resolutionId === 'original' || this.isConfidentialVideo() const suffix = this.activeResolutionId === 'original' || this.isConfidentialVideo()
? '?videoFileToken=' + this.videoFileToken ? '?videoFileToken=' + this.videoFileToken
: '' : ''
if (this.resolutionId === 'original') { if (this.activeResolutionId === 'original') {
return this.originalVideoFile.fileDownloadUrl + suffix return this.originalVideoFile.fileDownloadUrl + suffix
} }
@ -261,36 +172,13 @@ export class VideoDownloadComponent {
} }
} }
hasCaptions () { // ---------------------------------------------------------------------------
return this.getCaptions().length !== 0
}
getCaption () {
const caption = this.getCaptions()
.find(c => c.language.id === this.subtitleLanguageId)
if (!caption) {
logger.error(`Cannot find caption ${this.subtitleLanguageId}`)
return undefined
}
return caption
}
getCaptionLink () {
const caption = this.getCaption()
if (!caption) return ''
return window.location.origin + caption.captionPath
}
isConfidentialVideo () { isConfidentialVideo () {
return this.resolutionId === 'original' || videoRequiresFileToken(this.video) return this.activeResolutionId === 'original' || videoRequiresFileToken(this.video)
} }
switchToType (type: DownloadType) { // ---------------------------------------------------------------------------
this.type = type
}
hasMetadata () { hasMetadata () {
return !!this.videoFileMetadataFormat return !!this.videoFileMetadataFormat

View File

@ -0,0 +1,37 @@
<div class="form-group">
<div *ngIf="originalVideoFile" class="peertube-radio-container">
<input type="radio" name="video-file" id="original-file" [(ngModel)]="videoFileChosen" value="file-original">
<label for="original-file">
<strong i18n>Original file</strong>
<span class="muted">{{ originalVideoFile.size | bytes: 1 }} | {{ originalVideoFile.width }}x{{ originalVideoFile.height }}</span>
<my-global-icon i18n-ngbTooltip ngbTooltip="Other users cannot download the original file" iconName="shield"></my-global-icon>
</label>
</div>
@for (file of videoFiles; track file.id) {
<div class="peertube-radio-container">
<input type="radio" name="video-file" [id]="'file-' + file.id" [(ngModel)]="videoFileChosen" [value]="'file-' + file.id">
<label [for]="'file-' + file.id">
<strong>{{ file.resolution.label }}</strong>
<span class="muted">{{ getFileSize(file) | bytes: 1 }} @if (file.width) { | {{ file.width }}x{{ file.height }} }</span>
</label>
</div>
}
</div>
<div class="form-group" *ngIf="hasAudioSplitted()">
<my-peertube-checkbox inputName="includeAudio" [(ngModel)]="includeAudio" i18n-labelText labelText="Include audio"></my-peertube-checkbox>
</div>
<div class="modal-footer inputs">
<ng-content select="cancel-button"></ng-content>
<input type="submit" i18n-value value="Download" class="peertube-button orange-button" (click)="download()" />
</div>

View File

@ -0,0 +1,9 @@
@use '_variables' as *;
@use '_mixins' as *;
.peertube-radio-container strong {
@include margin-right(0.5rem);
display: inline-block;
min-width: 80px;
}

View File

@ -0,0 +1,130 @@
import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import {
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap'
import { maxBy } from '@peertube/peertube-core-utils'
import { VideoFile, VideoResolution, VideoSource } from '@peertube/peertube-models'
import { videoRequiresFileToken } from '@root-helpers/video'
import { GlobalIconComponent } from '../../shared-icons/global-icon.component'
import { BytesPipe } from '../../shared-main/angular/bytes.pipe'
import { VideoDetails } from '../../shared-main/video/video-details.model'
@Component({
selector: 'my-video-generate-download',
templateUrl: './video-generate-download.component.html',
styleUrls: [ './video-generate-download.component.scss' ],
standalone: true,
imports: [
NgIf,
FormsModule,
GlobalIconComponent,
PeertubeCheckboxComponent,
NgFor,
KeyValuePipe,
NgbTooltip,
NgTemplateOutlet,
NgClass,
BytesPipe
]
})
export class VideoGenerateDownloadComponent implements OnInit {
@Input({ required: true }) video: VideoDetails
@Input() originalVideoFile: VideoSource
@Input() videoFileToken: string
@Output() downloaded = new EventEmitter<void>()
includeAudio = true
videoFileChosen = ''
videoFiles: VideoFile[]
constructor (private videoService: VideoService) {
}
ngOnInit () {
this.videoFiles = this.buildVideoFiles()
if (this.videoFiles.length === 0) return
this.videoFileChosen = 'file-' + maxBy(this.videoFiles, 'resolution').id
}
getFileSize (file: VideoFile) {
if (file.hasAudio && file.hasVideo) return file.size
if (file.hasAudio) return file.size
if (this.includeAudio) {
const audio = this.findAudioFileOnly()
return file.size + (audio.size || 0)
}
return file.size
}
hasAudioSplitted () {
if (this.videoFileChosen === 'file-original') return false
return this.findCurrentFile().hasAudio === false &&
this.videoFiles.some(f => f.hasVideo === false && f.hasAudio === true)
}
// ---------------------------------------------------------------------------
download () {
window.location.assign(this.getVideoFileLink())
this.downloaded.emit()
}
// ---------------------------------------------------------------------------
getVideoFileLink () {
const suffix = this.videoFileChosen === 'file-original' || this.isConfidentialVideo()
? '?videoFileToken=' + this.videoFileToken
: ''
if (this.videoFileChosen === 'file-original') {
return this.originalVideoFile.fileDownloadUrl + suffix
}
const file = this.findCurrentFile()
if (!file) return ''
const files = [ file ]
if (this.hasAudioSplitted() && this.includeAudio) {
files.push(this.findAudioFileOnly())
}
return this.videoService.generateDownloadUrl({ video: this.video, files })
}
// ---------------------------------------------------------------------------
isConfidentialVideo () {
return this.videoFileChosen === 'file-original' || videoRequiresFileToken(this.video)
}
// ---------------------------------------------------------------------------
private buildVideoFiles () {
if (!this.video) return []
const hls = this.video.getHlsPlaylist()
if (hls) return hls.files
return this.video.files
}
private findCurrentFile () {
return this.videoFiles.find(f => this.videoFileChosen === 'file-' + f.id)
}
private findAudioFileOnly () {
return this.videoFiles.find(f => f.resolution.id === VideoResolution.H_NOVIDEO)
}
}

View File

@ -22,7 +22,7 @@ import { VideoBlockComponent } from '../shared-moderation/video-block.component'
import { VideoBlockService } from '../shared-moderation/video-block.service' import { VideoBlockService } from '../shared-moderation/video-block.service'
import { LiveStreamInformationComponent } from '../shared-video-live/live-stream-information.component' import { LiveStreamInformationComponent } from '../shared-video-live/live-stream-information.component'
import { VideoAddToPlaylistComponent } from '../shared-video-playlist/video-add-to-playlist.component' import { VideoAddToPlaylistComponent } from '../shared-video-playlist/video-add-to-playlist.component'
import { VideoDownloadComponent } from './video-download.component' import { VideoDownloadComponent } from './download/video-download.component'
export type VideoActionsDisplayType = { export type VideoActionsDisplayType = {
playlist?: boolean playlist?: boolean

View File

@ -1,177 +0,0 @@
<ng-template #modal let-hide="close">
<div class="modal-header">
<h4 class="modal-title">
<ng-container i18n>Download</ng-container>
<div class="peertube-select-container title-select" *ngIf="hasCaptions()">
<select id="type" name="type" [(ngModel)]="type" class="form-control">
<option value="video" i18n>Video</option>
<option value="subtitles" i18n>Subtitles</option>
</select>
</div>
</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body" [ngClass]="{ 'opacity-0': !loaded }">
<div class="alert alert-warning" *ngIf="isConfidentialVideo()" i18n>
The following link contains a private token and should not be shared with anyone.
</div>
<!-- Subtitle tab -->
<ng-container *ngIf="type === 'subtitles'">
<div ngbNav #subtitleNav="ngbNav" class="nav-tabs" [activeId]="subtitleLanguageId" (activeIdChange)="onSubtitleIdChange($event)">
<ng-container *ngFor="let caption of getCaptions()" [ngbNavItem]="caption.language.id">
<a ngbNavLink>
{{ caption.language.label }}
<ng-container *ngIf="caption.automaticallyGenerated" i18n>(auto-generated)</ng-container>
</a>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text
*ngIf="!isConfidentialVideo()"
[show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getLink()"
></my-input-text>
</div>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="subtitleNav"></div>
</ng-container>
<!-- Video tab -->
<ng-container *ngIf="type === 'video'">
<div ngbNav #resolutionNav="ngbNav" class="nav-tabs" [activeId]="resolutionId" (activeIdChange)="onResolutionIdChange($event)">
<ng-template #rootNavContent>
<div class="nav-content">
<my-input-text [show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getLink()"></my-input-text>
</div>
</ng-template>
<ng-container *ngIf="originalVideoFile" ngbNavItem="original">
<a ngbNavLink>
<ng-container i18n>Original file</ng-container>
<my-global-icon ngbTooltip="Other users cannot download the original file" iconName="shield"></my-global-icon>
</a>
<ng-template ngbNavContent>
<ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
</ng-template>
</ng-container>
<ng-container *ngFor="let file of getVideoFiles()" [ngbNavItem]="file.resolution.id">
<a ngbNavLink>{{ file.resolution.label }}</a>
<ng-template ngbNavContent>
<ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="resolutionNav"></div>
<div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true">
<div ngbNav #navMetadata="ngbNav" class="nav-tabs nav-metadata">
<ng-template #metadataInfo let-item>
<div class="metadata-attribute">
<span>{{ item.value.label }}</span>
@if (item.value.value) {
<span>{{ item.value.value }}</span>
} @else {
<span i18n>Unknown</span>
}
</div>
</ng-template>
<ng-container ngbNavItem>
<a ngbNavLink i18n>Format</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataFormat | keyvalue; track item) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
<a ngbNavLink i18n>Video stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataVideoStream | keyvalue; track item) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
<a ngbNavLink i18n>Audio stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataAudioStream | keyvalue; track item) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
</div>
<div *ngIf="hasMetadata()" [ngbNavOutlet]="navMetadata"></div>
<div [hidden]="originalVideoFile || !getVideoFile()?.torrentDownloadUrl" class="download-type">
<div class="peertube-radio-container">
<input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
<label i18n for="download-direct">Direct download</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
<label i18n for="download-torrent">Torrent (.torrent file)</label>
</div>
</div>
</div>
<button
(click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed"
class="advanced-filters-button button-unstyle"
[attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic"
>
<ng-container *ngIf="isAdvancedCustomizationCollapsed">
<span class="chevron-down"></span>
<ng-container i18n>More information/options</ng-container>
</ng-container>
<ng-container *ngIf="!isAdvancedCustomizationCollapsed">
<span class="chevron-up"></span>
<ng-container i18n>Less information/options</ng-container>
</ng-container>
</button>
</ng-container>
</div>
<div class="modal-footer inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="hide()" (key.enter)="hide()"
>
<input type="submit" i18n-value value="Download" class="peertube-button orange-button" (click)="download()" />
</div>
</ng-template>

View File

@ -1,19 +1,15 @@
// Thanks https://github.com/streamroot/videojs-hlsjs-plugin // Thanks https://github.com/streamroot/videojs-hlsjs-plugin
// We duplicated this plugin to choose the hls.js version we want, because streamroot only provide a bundled file // We duplicated this plugin to choose the hls.js version we want, because streamroot only provide a bundled file
import Hlsjs, { ErrorData, HlsConfig, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
import videojs from 'video.js'
import { logger } from '@root-helpers/logger' import { logger } from '@root-helpers/logger'
import { HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types' import Hlsjs, { ErrorData, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
import videojs from 'video.js'
import { HLSPluginOptions, HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types'
type ErrorCounts = { type ErrorCounts = {
[ type: string ]: number [ type: string ]: number
} }
type Metadata = {
levels: Level[]
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Source handler registration // Source handler registration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -126,10 +122,10 @@ export class Html5Hlsjs {
private maxNetworkErrorRecovery = 5 private maxNetworkErrorRecovery = 5
private hls: Hlsjs private hls: Hlsjs
private hlsjsConfig: Partial<HlsConfig & { cueHandler: any }> = null private hlsjsConfig: HLSPluginOptions = null
private _duration: number = null private _duration: number = null
private metadata: Metadata = null private metadata: ManifestParsedData = null
private isLive: boolean = null private isLive: boolean = null
private dvrDuration: number = null private dvrDuration: number = null
private edgeMargin: number = null private edgeMargin: number = null
@ -139,6 +135,8 @@ export class Html5Hlsjs {
error: null error: null
} }
private audioMode = false
constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) {
this.vjs = vjs this.vjs = vjs
this.source = source this.source = source
@ -206,50 +204,14 @@ export class Html5Hlsjs {
return this.vjs.createTimeRanges() return this.vjs.createTimeRanges()
} }
// See comment for `initialize` method.
dispose () { dispose () {
this.videoElement.removeEventListener('play', this.handlers.play) this.videoElement.removeEventListener('play', this.handlers.play)
this.videoElement.removeEventListener('error', this.handlers.error) this.videoElement.removeEventListener('error', this.handlers.error)
// FIXME: https://github.com/video-dev/hls.js/issues/4092
const untypedHLS = this.hls as any
untypedHLS.log = untypedHLS.warn = () => {
// empty
}
this.hls.destroy() this.hls.destroy()
} }
static addHook (type: string, callback: HookFn) { // ---------------------------------------------------------------------------
Html5Hlsjs.hooks[type] = this.hooks[type] || []
Html5Hlsjs.hooks[type].push(callback)
}
static removeHook (type: string, callback: HookFn) {
if (Html5Hlsjs.hooks[type] === undefined) return false
const index = Html5Hlsjs.hooks[type].indexOf(callback)
if (index === -1) return false
Html5Hlsjs.hooks[type].splice(index, 1)
return true
}
static removeAllHooks () {
Html5Hlsjs.hooks = {}
}
private _executeHooksFor (type: string) {
if (Html5Hlsjs.hooks[type] === undefined) {
return
}
// ES3 and IE < 9
for (let i = 0; i < Html5Hlsjs.hooks[type].length; i++) {
Html5Hlsjs.hooks[type][i](this.player, this.hls)
}
}
private _getHumanErrorMsg (error: { message: string, code?: number }) { private _getHumanErrorMsg (error: { message: string, code?: number }) {
switch (error.code) { switch (error.code) {
@ -265,11 +227,14 @@ export class Html5Hlsjs {
} }
this.hls.destroy() this.hls.destroy()
logger.info('bubbling error up to VIDEOJS') logger.info('bubbling error up to VIDEOJS')
this.tech.error = () => ({ this.tech.error = () => ({
...error, ...error,
message: this._getHumanErrorMsg(error) message: this._getHumanErrorMsg(error)
}) })
this.tech.trigger('error') this.tech.trigger('error')
} }
@ -335,16 +300,18 @@ export class Html5Hlsjs {
} }
} }
// ---------------------------------------------------------------------------
private buildLevelLabel (level: Level) { private buildLevelLabel (level: Level) {
if (this.player.srOptions_.levelLabelHandler) { if (this.player.srOptions_.levelLabelHandler) {
return this.player.srOptions_.levelLabelHandler(level as any) return this.player.srOptions_.levelLabelHandler(level, this.player)
} }
if (level.height) return level.height + 'p' if (level.height) return level.height + 'p'
if (level.width) return Math.round(level.width * 9 / 16) + 'p' if (level.width) return Math.round(level.width * 9 / 16) + 'p'
if (level.bitrate) return (level.bitrate / 1000) + 'kbps' if (level.bitrate) return (level.bitrate / 1000) + 'kbps'
return '0' return this.player.localize('Audio only')
} }
private _removeQuality (index: number) { private _removeQuality (index: number) {
@ -367,50 +334,61 @@ export class Html5Hlsjs {
label: this.buildLevelLabel(level), label: this.buildLevelLabel(level),
selected: level.id === this.hls.manualLevel, selected: level.id === this.hls.manualLevel,
selectCallback: () => { selectCallback: () => this.manuallySelectVideoLevel(index)
this.hls.currentLevel = index
}
}) })
}) })
// Add a manually injected "Audio only" quality that will reloads hls.js
const videoResolutions = resolutions.filter(r => r.height !== 0)
if (videoResolutions.length !== 0 && this.getSeparateAudioTrack()) {
const audioTrackUrl = this.getSeparateAudioTrack()
resolutions.push({
id: -2, // -1 is for "Auto quality"
label: this.player.localize('Audio only'),
selected: false,
selectCallback: () => {
if (this.audioMode) return
this.audioMode = true
this.updateToAudioOrVideo(audioTrackUrl)
}
})
}
resolutions.push({ resolutions.push({
id: -1, id: -1,
label: this.player.localize('Auto'), label: this.player.localize('Auto'),
selected: true, selected: true,
selectCallback: () => this.hls.currentLevel = -1 selectCallback: () => this.manuallySelectVideoLevel(-1)
}) })
this.player.peertubeResolutions().add(resolutions) this.player.peertubeResolutions().add(resolutions)
} }
private manuallySelectVideoLevel (index: number) {
if (this.audioMode) {
this.audioMode = false
this.updateToAudioOrVideo(this.source.src, index)
return
}
this.hls.currentLevel = index
}
private _startLoad () { private _startLoad () {
this.hls.startLoad(-1) this.hls.startLoad(-1)
this.videoElement.removeEventListener('play', this.handlers.play) this.videoElement.removeEventListener('play', this.handlers.play)
} }
private _oneLevelObjClone (obj: { [ id: string ]: any }) {
const result: { [id: string]: any } = {}
const objKeys = Object.keys(obj)
for (let i = 0; i < objKeys.length; i++) {
result[objKeys[i]] = obj[objKeys[i]]
}
return result
}
private _onMetaData (_event: any, data: ManifestParsedData) { private _onMetaData (_event: any, data: ManifestParsedData) {
// This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later // This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later
this.metadata = data this.metadata = data
this._notifyVideoQualities() this._notifyVideoQualities()
} }
private _initHlsjs () { private initialize () {
const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions this.buildBaseConfig()
const srOptions_ = this.player.srOptions_
const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig
// Hls.js will write to the reference thus change the object for later streams
this.hlsjsConfig = hlsjsConfigRef ? this._oneLevelObjClone(hlsjsConfigRef) : {}
if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) { if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) {
this.hlsjsConfig.autoStartLoad = false this.hlsjsConfig.autoStartLoad = false
@ -423,9 +401,10 @@ export class Html5Hlsjs {
this.videoElement.addEventListener('play', this.handlers.play) this.videoElement.addEventListener('play', this.handlers.play)
} }
this.hls = new Hlsjs(this.hlsjsConfig) const loader = this.hlsjsConfig.loaderBuilder()
this.hls = new Hlsjs({ ...this.hlsjsConfig, loader })
this._executeHooksFor('beforeinitialize') this.player.trigger('hlsjs-initialized', { hlsjs: this.hls, engine: loader.getEngine() })
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data)) this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data)) this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data))
@ -446,30 +425,83 @@ export class Html5Hlsjs {
if (this.isLive) this.maxNetworkErrorRecovery = 30 if (this.isLive) this.maxNetworkErrorRecovery = 30
}) })
this.registerLevelEventSwitch()
this.hls.once(Hlsjs.Events.FRAG_LOADED, () => { this.hls.once(Hlsjs.Events.FRAG_LOADED, () => {
// Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls` // Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls`
// Ref: https://github.com/videojs/videojs-contrib-hls#loadedmetadata // Ref: https://github.com/videojs/videojs-contrib-hls#loadedmetadata
this.tech.trigger('loadedmetadata') this.tech.trigger('loadedmetadata')
}) })
this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => {
const resolutionId = this.hls.autoLevelEnabled
? -1
: data.level
const autoResolutionChosenId = this.hls.autoLevelEnabled
? data.level
: -1
this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false })
})
this.hls.attachMedia(this.videoElement) this.hls.attachMedia(this.videoElement)
this.hls.loadSource(this.source.src) this.hls.loadSource(this.source.src)
} }
private initialize () { private updateToAudioOrVideo (newSource: string, startLevel?: number) {
this._initHlsjs() this.player.addClass('vjs-updating-resolution')
const currentTime = this.player.currentTime()
this.dispose()
this.buildBaseConfig()
this.hlsjsConfig.autoStartLoad = true
this.player.autoplay('play')
const loader = this.hlsjsConfig.loaderBuilder()
this.hls = new Hlsjs({
...this.hlsjsConfig,
loader,
startPosition: this.duration() === Infinity
? undefined
: currentTime,
startLevel
})
this.player.trigger('hlsjs-initialized', { hlsjs: this.hls, engine: loader.getEngine() })
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
this.registerLevelEventSwitch()
this.hls.attachMedia(this.videoElement)
this.hls.loadSource(newSource)
this.player.one('canplay', () => {
this.player.removeClass('vjs-updating-resolution')
})
}
private registerLevelEventSwitch () {
this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => {
let resolutionId = data.level
let autoResolutionChosenId = -1
if (this.audioMode) {
resolutionId = -2
} else if (this.hls.autoLevelEnabled) {
resolutionId = -1
autoResolutionChosenId = data.level
}
this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false })
})
}
private buildBaseConfig () {
const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions
const srOptions_ = this.player.srOptions_
const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig
// Hls.js will write to the reference thus change the object for later streams
this.hlsjsConfig = hlsjsConfigRef
? { ...hlsjsConfigRef }
: {}
}
private getSeparateAudioTrack () {
if (this.metadata.audioTracks.length === 0) return undefined
return this.metadata.audioTracks[0].url
} }
} }

View File

@ -6,6 +6,9 @@ import Hlsjs from 'hls.js'
import videojs from 'video.js' import videojs from 'video.js'
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
import { SettingsButton } from '../settings/settings-menu-button' import { SettingsButton } from '../settings/settings-menu-button'
import debug from 'debug'
const debugLogger = debug('peertube:player:p2p-media-loader')
const Plugin = videojs.getPlugin('plugin') const Plugin = videojs.getPlugin('plugin')
class P2pMediaLoaderPlugin extends Plugin { class P2pMediaLoaderPlugin extends Plugin {
@ -56,19 +59,23 @@ class P2pMediaLoaderPlugin extends Plugin {
return return
} }
// FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 player.on('hlsjs-initialized', (_: any, { hlsjs, engine }) => {
(videojs as any).Html5Hlsjs.addHook('beforeinitialize', (_videojsPlayer: any, hlsjs: any) => { this.p2pEngine?.removeAllListeners()
this.p2pEngine?.destroy()
clearInterval(this.networkInfoInterval)
this.hlsjs = hlsjs this.hlsjs = hlsjs
this.p2pEngine = engine
debugLogger('hls.js initialized, initializing p2p-media-loader plugin', { hlsjs, engine })
player.ready(() => this.initializePlugin())
}) })
player.src({ player.src({
type: options.type, type: options.type,
src: options.src src: options.src
}) })
player.ready(() => {
this.initializePlugin()
})
} }
dispose () { dispose () {
@ -76,9 +83,7 @@ class P2pMediaLoaderPlugin extends Plugin {
this.p2pEngine?.destroy() this.p2pEngine?.destroy()
this.hlsjs?.destroy() this.hlsjs?.destroy()
this.options.segmentValidator?.destroy(); this.options.segmentValidator?.destroy()
(videojs as any).Html5Hlsjs?.removeAllHooks()
clearInterval(this.networkInfoInterval) clearInterval(this.networkInfoInterval)
@ -112,8 +117,6 @@ class P2pMediaLoaderPlugin extends Plugin {
private initializePlugin () { private initializePlugin () {
initHlsJsPlayer(this.player, this.hlsjs) initHlsJsPlayer(this.player, this.hlsjs)
this.p2pEngine = this.options.loader.getEngine()
this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => { this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => {
if (navigator.onLine === false) return if (navigator.onLine === false) return

View File

@ -3,6 +3,9 @@ import { logger } from '@root-helpers/logger'
import { wait } from '@root-helpers/utils' import { wait } from '@root-helpers/utils'
import { removeQueryParams } from '@peertube/peertube-core-utils' import { removeQueryParams } from '@peertube/peertube-core-utils'
import { isSameOrigin } from '../common' import { isSameOrigin } from '../common'
import debug from 'debug'
const debugLogger = debug('peertube:player:segment-validator')
type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } } type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
@ -67,6 +70,8 @@ export class SegmentValidator {
throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
} }
debugLogger(`Validating ${filename} range ${segment.range}`)
const calculatedSha = await this.sha256Hex(segment.data) const calculatedSha = await this.sha256Hex(segment.data)
if (calculatedSha !== hashShouldBe) { if (calculatedSha !== hashShouldBe) {
throw new Error( throw new Error(

View File

@ -4,7 +4,13 @@ import { LiveVideoLatencyMode } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger' import { logger } from '@root-helpers/logger'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage' import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
import { P2PMediaLoader, P2PMediaLoaderPluginOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions } from '../../types' import {
HLSLoaderClass,
HLSPluginOptions,
P2PMediaLoaderPluginOptions,
PeerTubePlayerContructorOptions,
PeerTubePlayerLoadOptions
} from '../../types'
import { getRtcConfig, isSameOrigin } from '../common' import { getRtcConfig, isSameOrigin } from '../common'
import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder'
@ -47,7 +53,7 @@ export class HLSOptionsBuilder {
'filter:internal.player.p2p-media-loader.options.result', 'filter:internal.player.p2p-media-loader.options.result',
this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator }) this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator })
) )
const loader = new Engine(p2pMediaLoaderConfig).createLoaderClass() as unknown as P2PMediaLoader const loaderBuilder = () => new Engine(p2pMediaLoaderConfig).createLoaderClass() as unknown as HLSLoaderClass
const p2pMediaLoader: P2PMediaLoaderPluginOptions = { const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
requiresUserAuth: this.options.requiresUserAuth, requiresUserAuth: this.options.requiresUserAuth,
@ -58,19 +64,22 @@ export class HLSOptionsBuilder {
redundancyUrlManager, redundancyUrlManager,
type: 'application/x-mpegURL', type: 'application/x-mpegURL',
src: this.options.hls.playlistUrl, src: this.options.hls.playlistUrl,
segmentValidator, segmentValidator
loader
} }
const hlsjs = { const hlsjs = {
hlsjsConfig: this.getHLSJSOptions(loader), hlsjsConfig: this.getHLSJSOptions(loaderBuilder),
levelLabelHandler: (level: { height: number, width: number }) => { levelLabelHandler: (level: { height: number, width: number }, player: videojs.VideoJsPlayer) => {
const resolution = Math.min(level.height || 0, level.width || 0) const resolution = Math.min(level.height || 0, level.width || 0)
const file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution) const file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution)
// We don't have files for live videos // We don't have files for live videos
if (!file) return level.height if (!file) {
if (resolution === 0) return player.localize('Audio only')
return level.height + 'p'
}
let label = file.resolution.label let label = file.resolution.label
if (file.fps >= 50) label += file.fps if (file.fps >= 50) label += file.fps
@ -185,7 +194,7 @@ export class HLSOptionsBuilder {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private getHLSJSOptions (loader: P2PMediaLoader) { private getHLSJSOptions (loaderBuilder: () => HLSLoaderClass): HLSPluginOptions {
const specificLiveOrVODOptions = this.options.isLive const specificLiveOrVODOptions = this.options.isLive
? this.getHLSLiveOptions() ? this.getHLSLiveOptions()
: this.getHLSVODOptions() : this.getHLSVODOptions()
@ -194,7 +203,7 @@ export class HLSOptionsBuilder {
capLevelToPlayerSize: true, capLevelToPlayerSize: true,
autoStartLoad: false, autoStartLoad: false,
loader, loaderBuilder,
...specificLiveOrVODOptions ...specificLiveOrVODOptions
} }

View File

@ -56,7 +56,9 @@ class PeerTubeResolutionsPlugin extends Plugin {
if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return
this.autoResolutionChosenId = autoResolutionChosenId if (autoResolutionChosenId !== undefined) {
this.autoResolutionChosenId = autoResolutionChosenId
}
for (const r of this.resolutions) { for (const r of this.resolutions) {
r.selected = r.id === id r.selected = r.id === id

View File

@ -42,7 +42,7 @@ class ResolutionMenuButton extends MenuButton {
for (const r of resolutions) { for (const r of resolutions) {
const label = r.label === '0p' const label = r.label === '0p'
? this.player().localize('Audio-only') ? this.player().localize('Audio only')
: r.label : r.label
const component = new ResolutionMenuItem( const component = new ResolutionMenuItem(

View File

@ -1,8 +1,10 @@
import { HlsConfig, Level } from 'hls.js'
import videojs from 'video.js'
import { Engine } from '@peertube/p2p-media-loader-hlsjs' import { Engine } from '@peertube/p2p-media-loader-hlsjs'
import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
import type { HlsConfig, Level, Loader, LoaderContext } from 'hls.js'
import videojs from 'video.js'
import { BezelsPlugin } from '../shared/bezels/bezels-plugin' import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
import { ContextMenuPlugin } from '../shared/context-menu'
import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin' import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin'
@ -10,6 +12,7 @@ import { PeerTubeMobilePlugin } from '../shared/mobile/peertube-mobile-plugin'
import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
import { PeerTubePlugin } from '../shared/peertube/peertube-plugin' import { PeerTubePlugin } from '../shared/peertube/peertube-plugin'
import { PlaylistPlugin } from '../shared/playlist/playlist-plugin' import { PlaylistPlugin } from '../shared/playlist/playlist-plugin'
import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin'
@ -18,9 +21,6 @@ import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin'
import { UpNextPlugin } from '../shared/upnext/upnext-plugin' import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
import { PlayerMode } from './peertube-player-options' import { PlayerMode } from './peertube-player-options'
import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
import { ContextMenuPlugin } from '../shared/context-menu'
declare module 'video.js' { declare module 'video.js' {
@ -79,10 +79,10 @@ export interface VideoJSTechHLS extends videojs.Tech {
export interface HlsjsConfigHandlerOptions { export interface HlsjsConfigHandlerOptions {
hlsjsConfig?: HlsConfig hlsjsConfig?: HlsConfig
levelLabelHandler?: (level: Level) => string levelLabelHandler?: (level: Level, player: videojs.Player) => string
} }
type PeerTubeResolution = { export type PeerTubeResolution = {
id: number id: number
height?: number height?: number
@ -94,21 +94,21 @@ type PeerTubeResolution = {
selectCallback: () => void selectCallback: () => void
} }
type VideoJSCaption = { export type VideoJSCaption = {
label: string label: string
language: string language: string
src: string src: string
automaticallyGenerated: boolean automaticallyGenerated: boolean
} }
type VideoJSStoryboard = { export type VideoJSStoryboard = {
url: string url: string
width: number width: number
height: number height: number
interval: number interval: number
} }
type PeerTubePluginOptions = { export type PeerTubePluginOptions = {
autoPlayerRatio: { autoPlayerRatio: {
cssRatioVariable: string cssRatioVariable: string
cssPlayerPortraitModeVariable: string cssPlayerPortraitModeVariable: string
@ -136,14 +136,14 @@ type PeerTubePluginOptions = {
poster: () => string poster: () => string
} }
type MetricsPluginOptions = { export type MetricsPluginOptions = {
mode: () => PlayerMode mode: () => PlayerMode
metricsUrl: () => string metricsUrl: () => string
metricsInterval: () => number metricsInterval: () => number
videoUUID: () => string videoUUID: () => string
} }
type ContextMenuPluginOptions = { export type ContextMenuPluginOptions = {
content: () => { content: () => {
icon?: string icon?: string
label: string label: string
@ -151,23 +151,23 @@ type ContextMenuPluginOptions = {
}[] }[]
} }
type ContextMenuItemOptions = { export type ContextMenuItemOptions = {
listener: (e: videojs.EventTarget.Event) => void listener: (e: videojs.EventTarget.Event) => void
label: string label: string
} }
type StoryboardOptions = { export type StoryboardOptions = {
url: string url: string
width: number width: number
height: number height: number
interval: number interval: number
} }
type ChaptersOptions = { export type ChaptersOptions = {
chapters: VideoChapter[] chapters: VideoChapter[]
} }
type PlaylistPluginOptions = { export type PlaylistPluginOptions = {
elements: VideoPlaylistElement[] elements: VideoPlaylistElement[]
playlist: VideoPlaylist playlist: VideoPlaylist
@ -177,7 +177,7 @@ type PlaylistPluginOptions = {
onItemClicked: (element: VideoPlaylistElement) => void onItemClicked: (element: VideoPlaylistElement) => void
} }
type UpNextPluginOptions = { export type UpNextPluginOptions = {
timeout: number timeout: number
next: () => void next: () => void
@ -186,33 +186,40 @@ type UpNextPluginOptions = {
isSuspended: () => boolean isSuspended: () => boolean
} }
type ProgressBarMarkerComponentOptions = { export type ProgressBarMarkerComponentOptions = {
timecode: number timecode: number
} }
type NextPreviousVideoButtonOptions = { export type NextPreviousVideoButtonOptions = {
type: 'next' | 'previous' type: 'next' | 'previous'
handler?: () => void handler?: () => void
isDisplayed: () => boolean isDisplayed: () => boolean
isDisabled: () => boolean isDisabled: () => boolean
} }
type PeerTubeLinkButtonOptions = { export type PeerTubeLinkButtonOptions = {
isDisplayed: () => boolean isDisplayed: () => boolean
shortUUID: () => string shortUUID: () => string
instanceName: string instanceName: string
} }
type TheaterButtonOptions = { export type TheaterButtonOptions = {
isDisplayed: () => boolean isDisplayed: () => boolean
} }
type WebVideoPluginOptions = { export type WebVideoPluginOptions = {
videoFiles: VideoFile[] videoFiles: VideoFile[]
videoFileToken: () => string videoFileToken: () => string
} }
type P2PMediaLoaderPluginOptions = { export type HLSLoaderClass = {
new (confg: HlsConfig): Loader<LoaderContext>
getEngine(): Engine
}
export type HLSPluginOptions = Partial<HlsConfig & { cueHandler: any, loaderBuilder: () => HLSLoaderClass }>
export type P2PMediaLoaderPluginOptions = {
redundancyUrlManager: RedundancyUrlManager | null redundancyUrlManager: RedundancyUrlManager | null
segmentValidator: SegmentValidator | null segmentValidator: SegmentValidator | null
@ -221,8 +228,6 @@ type P2PMediaLoaderPluginOptions = {
p2pEnabled: boolean p2pEnabled: boolean
loader: P2PMediaLoader
requiresUserAuth: boolean requiresUserAuth: boolean
videoFileToken: () => string videoFileToken: () => string
} }
@ -233,7 +238,7 @@ export type P2PMediaLoader = {
destroy: () => void destroy: () => void
} }
type VideoJSPluginOptions = { export type VideoJSPluginOptions = {
playlist?: PlaylistPluginOptions playlist?: PlaylistPluginOptions
peertube: PeerTubePluginOptions peertube: PeerTubePluginOptions
@ -244,7 +249,7 @@ type VideoJSPluginOptions = {
p2pMediaLoader?: P2PMediaLoaderPluginOptions p2pMediaLoader?: P2PMediaLoaderPluginOptions
} }
type LoadedQualityData = { export type LoadedQualityData = {
qualitySwitchCallback: (resolutionId: number, type: 'video') => void qualitySwitchCallback: (resolutionId: number, type: 'video') => void
qualityData: { qualityData: {
video: { video: {
@ -255,17 +260,17 @@ type LoadedQualityData = {
} }
} }
type ResolutionUpdateData = { export type ResolutionUpdateData = {
auto: boolean auto: boolean
resolutionId: number resolutionId: number
id?: number id?: number
} }
type AutoResolutionUpdateData = { export type AutoResolutionUpdateData = {
possible: boolean possible: boolean
} }
type PlayerNetworkInfo = { export type PlayerNetworkInfo = {
source: 'web-video' | 'p2p-media-loader' source: 'web-video' | 'p2p-media-loader'
http: { http: {
@ -288,34 +293,8 @@ type PlayerNetworkInfo = {
bandwidthEstimate?: number bandwidthEstimate?: number
} }
type PlaylistItemOptions = { export type PlaylistItemOptions = {
element: VideoPlaylistElement element: VideoPlaylistElement
onClicked: () => void onClicked: () => void
} }
export {
PlayerNetworkInfo,
TheaterButtonOptions,
VideoJSStoryboard,
PlaylistItemOptions,
NextPreviousVideoButtonOptions,
ResolutionUpdateData,
AutoResolutionUpdateData,
ProgressBarMarkerComponentOptions,
PlaylistPluginOptions,
MetricsPluginOptions,
VideoJSCaption,
PeerTubePluginOptions,
WebVideoPluginOptions,
P2PMediaLoaderPluginOptions,
ContextMenuItemOptions,
PeerTubeResolution,
VideoJSPluginOptions,
ContextMenuPluginOptions,
UpNextPluginOptions,
LoadedQualityData,
StoryboardOptions,
ChaptersOptions,
PeerTubeLinkButtonOptions
}

View File

@ -0,0 +1 @@
VITE_BACKEND_URL="http://localhost:9000"

View File

@ -24,7 +24,8 @@ import {
PlaylistFetcher, PlaylistFetcher,
PlaylistTracker, PlaylistTracker,
Translations, Translations,
VideoFetcher VideoFetcher,
getBackendUrl
} from './shared' } from './shared'
import { PlayerHTML } from './shared/player-html' import { PlayerHTML } from './shared/player-html'
@ -58,7 +59,7 @@ export class PeerTubeEmbed {
private requiresPassword: boolean private requiresPassword: boolean
constructor (videoWrapperId: string) { constructor (videoWrapperId: string) {
logger.registerServerSending(window.location.origin) logger.registerServerSending(getBackendUrl())
this.http = new AuthHTTP() this.http = new AuthHTTP()
@ -73,7 +74,9 @@ export class PeerTubeEmbed {
try { try {
this.config = JSON.parse((window as any)['PeerTubeServerConfig']) this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
} catch (err) { } catch (err) {
logger.error('Cannot parse HTML config.', err) if (!(import.meta as any).env.DEV) {
logger.error('Cannot parse HTML config.', err)
}
} }
} }
@ -90,12 +93,12 @@ export class PeerTubeEmbed {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async init () { async init () {
this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language) this.translationsPromise = TranslationsManager.getServerTranslations(getBackendUrl(), navigator.language)
this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player') this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player')
// Issue when we parsed config from HTML, fallback to API // Issue when we parsed config from HTML, fallback to API
if (!this.config) { if (!this.config) {
this.config = await this.http.fetch('/api/v1/config', { optionalAuth: false }) this.config = await this.http.fetch(getBackendUrl() + '/api/v1/config', { optionalAuth: false })
.then(res => res.json()) .then(res => res.json())
} }
@ -265,7 +268,7 @@ export class PeerTubeEmbed {
// If already played, we are in a playlist so we don't want to display the poster between videos // If already played, we are in a playlist so we don't want to display the poster between videos
if (!this.alreadyPlayed) { if (!this.alreadyPlayed) {
this.peertubePlayer.setPoster(window.location.origin + video.previewPath) this.peertubePlayer.setPoster(getBackendUrl() + video.previewPath)
} }
const playlist = this.playlistTracker const playlist = this.playlistTracker
@ -351,6 +354,16 @@ export class PeerTubeEmbed {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private getResourceId () { private getResourceId () {
const search = window.location.search
if (search.startsWith('?videoId=')) {
return search.replace(/^\?videoId=/, '')
}
if (search.startsWith('?videoPlaylistId=')) {
return search.replace(/^\?videoPlaylistId=/, '')
}
const urlParts = window.location.pathname.split('/') const urlParts = window.location.pathname.split('/')
return urlParts[urlParts.length - 1] return urlParts[urlParts.length - 1]
} }

View File

@ -5,5 +5,6 @@ export * from './player-html'
export * from './player-options-builder' export * from './player-options-builder'
export * from './playlist-fetcher' export * from './playlist-fetcher'
export * from './playlist-tracker' export * from './playlist-tracker'
export * from './url'
export * from './translations' export * from './translations'
export * from './video-fetcher' export * from './video-fetcher'

View File

@ -1,7 +1,8 @@
import { Socket } from 'socket.io-client'
import { LiveVideoEventPayload, VideoDetails, VideoState, VideoStateType } from '@peertube/peertube-models' import { LiveVideoEventPayload, VideoDetails, VideoState, VideoStateType } from '@peertube/peertube-models'
import { Socket } from 'socket.io-client'
import { PlayerHTML } from './player-html' import { PlayerHTML } from './player-html'
import { Translations } from './translations' import { Translations } from './translations'
import { getBackendUrl } from './url'
export class LiveManager { export class LiveManager {
private liveSocket: Socket private liveSocket: Socket
@ -22,7 +23,7 @@ export class LiveManager {
if (!this.liveSocket) { if (!this.liveSocket) {
const io = (await import('socket.io-client')).io const io = (await import('socket.io-client')).io
this.liveSocket = io(window.location.origin + '/live-videos') this.liveSocket = io(getBackendUrl() + '/live-videos')
} }
const listener = (payload: LiveVideoEventPayload) => { const listener = (payload: LiveVideoEventPayload) => {

View File

@ -4,6 +4,7 @@ import { PluginInfo, PluginsManager } from '../../../root-helpers'
import { RegisterClientHelpers } from '../../../types' import { RegisterClientHelpers } from '../../../types'
import { AuthHTTP } from './auth-http' import { AuthHTTP } from './auth-http'
import { Translations } from './translations' import { Translations } from './translations'
import { getBackendUrl } from './url'
export class PeerTubePlugin { export class PeerTubePlugin {
@ -83,6 +84,6 @@ export class PeerTubePlugin {
} }
private getPluginUrl () { private getPluginUrl () {
return window.location.origin + '/api/v1/plugins' return getBackendUrl() + '/api/v1/plugins'
} }
} }

View File

@ -27,6 +27,7 @@ import { PlayerHTML } from './player-html'
import { PlaylistTracker } from './playlist-tracker' import { PlaylistTracker } from './playlist-tracker'
import { Translations } from './translations' import { Translations } from './translations'
import { VideoFetcher } from './video-fetcher' import { VideoFetcher } from './video-fetcher'
import { getBackendUrl } from './url'
export class PlayerOptionsBuilder { export class PlayerOptionsBuilder {
private autoplay: boolean private autoplay: boolean
@ -190,7 +191,7 @@ export class PlayerOptionsBuilder {
videoViewIntervalMs: serverConfig.views.videos.watchingInterval.anonymous, videoViewIntervalMs: serverConfig.views.videos.watchingInterval.anonymous,
metricsUrl: serverConfig.openTelemetry.metrics.enabled metricsUrl: serverConfig.openTelemetry.metrics.enabled
? window.location.origin + '/api/v1/metrics/playback' ? getBackendUrl() + '/api/v1/metrics/playback'
: null, : null,
metricsInterval: serverConfig.openTelemetry.metrics.playbackStatsInterval, metricsInterval: serverConfig.openTelemetry.metrics.playbackStatsInterval,
@ -204,7 +205,7 @@ export class PlayerOptionsBuilder {
theaterButton: false, theaterButton: false,
serverUrl: window.location.origin, serverUrl: getBackendUrl(),
language: navigator.language, language: navigator.language,
pluginsManager: this.peertubePlugin.getPluginsManager(), pluginsManager: this.peertubePlugin.getPluginsManager(),
@ -292,9 +293,9 @@ export class PlayerOptionsBuilder {
duration: video.duration, duration: video.duration,
videoRatio: video.aspectRatio, videoRatio: video.aspectRatio,
poster: window.location.origin + video.previewPath, poster: getBackendUrl() + video.previewPath,
embedUrl: window.location.origin + video.embedPath, embedUrl: getBackendUrl() + video.embedPath,
embedTitle: video.name, embedTitle: video.name,
requiresUserAuth: videoRequiresUserAuth(video), requiresUserAuth: videoRequiresUserAuth(video),
@ -333,7 +334,7 @@ export class PlayerOptionsBuilder {
if (!storyboards || storyboards.length === 0) return undefined if (!storyboards || storyboards.length === 0) return undefined
return { return {
url: window.location.origin + storyboards[0].storyboardPath, url: getBackendUrl() + storyboards[0].storyboardPath,
height: storyboards[0].spriteHeight, height: storyboards[0].spriteHeight,
width: storyboards[0].spriteWidth, width: storyboards[0].spriteWidth,
interval: storyboards[0].spriteDuration interval: storyboards[0].spriteDuration
@ -426,7 +427,7 @@ export class PlayerOptionsBuilder {
label: peertubeTranslate(c.language.label, translations), label: peertubeTranslate(c.language.label, translations),
language: c.language.id, language: c.language.id,
automaticallyGenerated: c.automaticallyGenerated, automaticallyGenerated: c.automaticallyGenerated,
src: window.location.origin + c.captionPath src: getBackendUrl() + c.captionPath
})) }))
} }

View File

@ -1,6 +1,7 @@
import { HttpStatusCode, ResultList, VideoPlaylistElement } from '@peertube/peertube-models' import { HttpStatusCode, ResultList, VideoPlaylistElement } from '@peertube/peertube-models'
import { logger } from '../../../root-helpers' import { logger } from '../../../root-helpers'
import { AuthHTTP } from './auth-http' import { AuthHTTP } from './auth-http'
import { getBackendUrl } from './url'
export class PlaylistFetcher { export class PlaylistFetcher {
@ -68,6 +69,6 @@ export class PlaylistFetcher {
} }
private getPlaylistUrl (id: string) { private getPlaylistUrl (id: string) {
return window.location.origin + '/api/v1/video-playlists/' + id return getBackendUrl() + '/api/v1/video-playlists/' + id
} }
} }

View File

@ -0,0 +1,3 @@
export function getBackendUrl () {
return (import.meta as any).env.VITE_BACKEND_URL || window.location.origin
}

View File

@ -2,6 +2,7 @@ import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '@peertube/p
import { logger } from '../../../root-helpers' import { logger } from '../../../root-helpers'
import { PeerTubeServerError } from '../../../types' import { PeerTubeServerError } from '../../../types'
import { AuthHTTP } from './auth-http' import { AuthHTTP } from './auth-http'
import { getBackendUrl } from './url'
export class VideoFetcher { export class VideoFetcher {
@ -70,11 +71,11 @@ export class VideoFetcher {
} }
private getVideoUrl (id: string) { private getVideoUrl (id: string) {
return window.location.origin + '/api/v1/videos/' + id return getBackendUrl() + '/api/v1/videos/' + id
} }
private getLiveUrl (videoId: string) { private getLiveUrl (videoId: string) {
return window.location.origin + '/api/v1/videos/live/' + videoId return getBackendUrl() + '/api/v1/videos/live/' + videoId
} }
private loadStoryboards (videoUUID: string): Promise<Response> { private loadStoryboards (videoUUID: string): Promise<Response> {
@ -82,7 +83,7 @@ export class VideoFetcher {
} }
private getStoryboardsUrl (videoId: string) { private getStoryboardsUrl (videoId: string) {
return window.location.origin + '/api/v1/videos/' + videoId + '/storyboards' return getBackendUrl() + '/api/v1/videos/' + videoId + '/storyboards'
} }
private getVideoTokenUrl (id: string) { private getVideoTokenUrl (id: string) {

View File

@ -9,15 +9,40 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, '../../../') const root = resolve(__dirname, '../../../')
export default defineConfig(() => { export default defineConfig(({ mode }) => {
return { return {
base: '/client/standalone/videos/', base: mode === 'development'
? ''
: '/client/standalone/videos/',
root: resolve(root, 'src', 'standalone', 'videos'), root: resolve(root, 'src', 'standalone', 'videos'),
server: {
proxy: {
'^/(videos|video-playlists)/(test-)?embed/[^\/\.]+$': {
target: 'http://localhost:5173',
rewrite: (path) => {
return path.replace('/videos/embed/', 'embed.html?videoId=')
.replace('/videos/test-embed/', 'test-embed.html?')
.replace('/video-playlists/embed/', 'embed.html?videoPlaylistId=')
.replace('/video-playlists/test-embed/', 'test-embed.html?videoPlaylistId=')
}
},
'^/(videos|video-playlists)/(test-)?embed/.*': {
target: 'http://localhost:5173',
rewrite: (path) => {
return path.replace(/\/(videos|video-playlists)\/(test-)?embed\//, '')
}
},
'^/lazy-static': {
target: 'http://localhost:9000'
}
}
},
resolve: { resolve: {
alias: [ alias: [
{ find: /^video.js$/, replacement: resolve(root, './node_modules/video.js/core.js') }, { find: /^video.js$/, replacement: resolve(root, './node_modules/video.js/core.js') },
{ find: /^hls.js$/, replacement: resolve(root, './node_modules/hls.js/dist/hls.light.mjs') },
{ find: '@root-helpers', replacement: resolve(root, './src/root-helpers') } { find: '@root-helpers', replacement: resolve(root, './src/root-helpers') }
], ],
}, },
@ -33,6 +58,7 @@ export default defineConfig(() => {
build: { build: {
outDir: resolve(root, 'dist', 'standalone', 'videos'), outDir: resolve(root, 'dist', 'standalone', 'videos'),
emptyOutDir: true, emptyOutDir: true,
sourcemap: mode === 'development',
target: [ 'firefox78', 'ios12' ], target: [ 'firefox78', 'ios12' ],

View File

@ -28,9 +28,6 @@
], ],
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"hls.js": [
"node_modules/hls.js/dist/hls.light"
],
"video.js": [ "video.js": [
"node_modules/video.js/core" "node_modules/video.js/core"
], ],

View File

@ -56,6 +56,11 @@ rates_limit:
# 500 attempts in 10 seconds (to not break crawlers) # 500 attempts in 10 seconds (to not break crawlers)
window: 10 seconds window: 10 seconds
max: 500 max: 500
download_generate_video: # A light FFmpeg process is used to generate videos (to merge audio and video streams for example)
# 5 attempts in 5 seconds
window: 5 seconds
max: 5
oauth2: oauth2:
token_lifetime: token_lifetime:
@ -588,7 +593,7 @@ transcoding:
profile: 'default' profile: 'default'
resolutions: # Only created if the original video has a higher resolution, uses more storage! resolutions: # Only created if the original video has a higher resolution, uses more storage!
0p: false # audio-only (creates mp4 without video stream, always created when enabled) 0p: false # audio-only (creates mp4 without video stream)
144p: false 144p: false
240p: false 240p: false
360p: false 360p: false
@ -616,6 +621,11 @@ transcoding:
hls: hls:
enabled: true enabled: true
# Store the audio stream in a separate file from the video
# This option adds the ability for the HLS player to propose the "Audio only" quality to users
# It also saves disk space by not duplicating the audio stream in each resolution file
split_audio_and_video: false
live: live:
enabled: false enabled: false
@ -693,6 +703,7 @@ live:
profile: 'default' profile: 'default'
resolutions: resolutions:
0p: false # Audio only
144p: false 144p: false
240p: false 240p: false
360p: false 360p: false

View File

@ -54,6 +54,11 @@ rates_limit:
# 500 attempts in 10 seconds (to not break crawlers) # 500 attempts in 10 seconds (to not break crawlers)
window: 10 seconds window: 10 seconds
max: 500 max: 500
download_generate_video: # A light FFmpeg process is used to generate videos (to merge audio and video streams for example)
# 5 attempts in 5 seconds
window: 5 seconds
max: 5
oauth2: oauth2:
token_lifetime: token_lifetime:
@ -598,7 +603,7 @@ transcoding:
profile: 'default' profile: 'default'
resolutions: # Only created if the original video has a higher resolution, uses more storage! resolutions: # Only created if the original video has a higher resolution, uses more storage!
0p: false # audio-only (creates mp4 without video stream, always created when enabled) 0p: false # audio-only (creates mp4 without video stream)
144p: false 144p: false
240p: false 240p: false
360p: false 360p: false
@ -626,6 +631,11 @@ transcoding:
hls: hls:
enabled: true enabled: true
# Store the audio stream in a separate file from the video
# This option adds the ability for the HLS player to propose the "Audio only" quality to users
# It also saves disk space by not duplicating the audio stream in each resolution file
split_audio_and_video: false
live: live:
enabled: false enabled: false
@ -703,6 +713,7 @@ live:
profile: 'default' profile: 'default'
resolutions: resolutions:
0p: false # Audio only
144p: false 144p: false
240p: false 240p: false
360p: false 360p: false

View File

@ -1,4 +1,4 @@
import { pick, promisify0 } from '@peertube/peertube-core-utils' import { arrayify, pick, promisify0 } from '@peertube/peertube-core-utils'
import { import {
AvailableEncoders, AvailableEncoders,
EncoderOptionsBuilder, EncoderOptionsBuilder,
@ -8,6 +8,7 @@ import {
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { MutexInterface } from 'async-mutex' import { MutexInterface } from 'async-mutex'
import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
import { Readable } from 'node:stream'
export interface FFmpegCommandWrapperOptions { export interface FFmpegCommandWrapperOptions {
availableEncoders?: AvailableEncoders availableEncoders?: AvailableEncoders
@ -83,15 +84,19 @@ export class FFmpegCommandWrapper {
this.command = undefined this.command = undefined
} }
buildCommand (input: string, inputFileMutexReleaser?: MutexInterface.Releaser) { buildCommand (inputs: (string | Readable)[] | string | Readable, inputFileMutexReleaser?: MutexInterface.Releaser) {
if (this.command) throw new Error('Command is already built') if (this.command) throw new Error('Command is already built')
// We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
this.command = ffmpeg(input, { this.command = ffmpeg({
niceness: this.niceness, niceness: this.niceness,
cwd: this.tmpDirectory cwd: this.tmpDirectory
}) })
for (const input of arrayify(inputs)) {
this.command.input(input)
}
if (this.threads > 0) { if (this.threads > 0) {
// If we don't set any threads ffmpeg will chose automatically // If we don't set any threads ffmpeg will chose automatically
this.command.outputOption('-threads ' + this.threads) this.command.outputOption('-threads ' + this.threads)
@ -117,7 +122,10 @@ export class FFmpegCommandWrapper {
this.command.on('start', cmdline => { shellCommand = cmdline }) this.command.on('start', cmdline => { shellCommand = cmdline })
this.command.on('error', (err, stdout, stderr) => { this.command.on('error', (err, stdout, stderr) => {
if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags }) if (silent !== true) this.logger.error('Error in ffmpeg.', { err, stdout, stderr, shellCommand, ...this.lTags })
err.stdout = stdout
err.stderr = stderr
if (this.onError) this.onError(err) if (this.onError) this.onError(err)

View File

@ -0,0 +1,26 @@
import { Readable, Writable } from 'stream'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
export class FFmpegContainer {
private readonly commandWrapper: FFmpegCommandWrapper
constructor (options: FFmpegCommandWrapperOptions) {
this.commandWrapper = new FFmpegCommandWrapper(options)
}
mergeInputs (options: {
inputs: (Readable | string)[]
output: Writable
logError: boolean
}) {
const { inputs, output, logError } = options
this.commandWrapper.buildCommand(inputs)
.outputOption('-c copy')
.outputOption('-movflags frag_keyframe+empty_moov')
.format('mp4')
.output(output)
return this.commandWrapper.runCommand({ silent: !logError })
}
}

View File

@ -1,7 +1,19 @@
import { MutexInterface } from 'async-mutex'
import { FilterSpecification } from 'fluent-ffmpeg' import { FilterSpecification } from 'fluent-ffmpeg'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
import { presetVOD } from './shared/presets.js'
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js' import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js'
import { presetVOD } from './shared/presets.js'
type BaseStudioOptions = {
videoInputPath: string
separatedAudioInputPath?: string
outputPath: string
// Will be released after the ffmpeg started
// To prevent a bug where the input file does not exist anymore when running ffmpeg
inputFileMutexReleaser?: MutexInterface.Releaser
}
export class FFmpegEdition { export class FFmpegEdition {
private readonly commandWrapper: FFmpegCommandWrapper private readonly commandWrapper: FFmpegCommandWrapper
@ -10,25 +22,27 @@ export class FFmpegEdition {
this.commandWrapper = new FFmpegCommandWrapper(options) this.commandWrapper = new FFmpegCommandWrapper(options)
} }
async cutVideo (options: { async cutVideo (options: BaseStudioOptions & {
inputPath: string
outputPath: string
start?: number start?: number
end?: number end?: number
}) { }) {
const { inputPath, outputPath } = options const { videoInputPath, separatedAudioInputPath, outputPath, inputFileMutexReleaser } = options
const mainProbe = await ffprobePromise(inputPath) const mainProbe = await ffprobePromise(videoInputPath)
const fps = await getVideoStreamFPS(inputPath, mainProbe) const fps = await getVideoStreamFPS(videoInputPath, mainProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, mainProbe)
const command = this.commandWrapper.buildCommand(inputPath) const command = this.commandWrapper.buildCommand(this.buildInputs(options), inputFileMutexReleaser)
.output(outputPath) .output(outputPath)
await presetVOD({ await presetVOD({
commandWrapper: this.commandWrapper, commandWrapper: this.commandWrapper,
input: inputPath,
videoInputPath,
separatedAudioInputPath,
resolution, resolution,
videoStreamOnly: false,
fps, fps,
canCopyAudio: false, canCopyAudio: false,
canCopyVideo: false canCopyVideo: false
@ -45,10 +59,8 @@ export class FFmpegEdition {
await this.commandWrapper.runCommand() await this.commandWrapper.runCommand()
} }
async addWatermark (options: { async addWatermark (options: BaseStudioOptions & {
inputPath: string
watermarkPath: string watermarkPath: string
outputPath: string
videoFilters: { videoFilters: {
watermarkSizeRatio: number watermarkSizeRatio: number
@ -56,21 +68,23 @@ export class FFmpegEdition {
verticalMarginRatio: number verticalMarginRatio: number
} }
}) { }) {
const { watermarkPath, inputPath, outputPath, videoFilters } = options const { watermarkPath, videoInputPath, separatedAudioInputPath, outputPath, videoFilters, inputFileMutexReleaser } = options
const videoProbe = await ffprobePromise(inputPath) const videoProbe = await ffprobePromise(videoInputPath)
const fps = await getVideoStreamFPS(inputPath, videoProbe) const fps = await getVideoStreamFPS(videoInputPath, videoProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe) const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, videoProbe)
const command = this.commandWrapper.buildCommand(inputPath) const command = this.commandWrapper.buildCommand([ ...this.buildInputs(options), watermarkPath ], inputFileMutexReleaser)
.output(outputPath) .output(outputPath)
command.input(watermarkPath)
await presetVOD({ await presetVOD({
commandWrapper: this.commandWrapper, commandWrapper: this.commandWrapper,
input: inputPath,
videoInputPath,
separatedAudioInputPath,
resolution, resolution,
videoStreamOnly: false,
fps, fps,
canCopyAudio: true, canCopyAudio: true,
canCopyVideo: false canCopyVideo: false
@ -103,27 +117,24 @@ export class FFmpegEdition {
await this.commandWrapper.runCommand() await this.commandWrapper.runCommand()
} }
async addIntroOutro (options: { async addIntroOutro (options: BaseStudioOptions & {
inputPath: string
introOutroPath: string introOutroPath: string
outputPath: string
type: 'intro' | 'outro' type: 'intro' | 'outro'
}) { }) {
const { introOutroPath, inputPath, outputPath, type } = options const { introOutroPath, videoInputPath, separatedAudioInputPath, outputPath, type, inputFileMutexReleaser } = options
const mainProbe = await ffprobePromise(inputPath) const mainProbe = await ffprobePromise(videoInputPath)
const fps = await getVideoStreamFPS(inputPath, mainProbe) const fps = await getVideoStreamFPS(videoInputPath, mainProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, mainProbe)
const mainHasAudio = await hasAudioStream(inputPath, mainProbe) const mainHasAudio = await hasAudioStream(separatedAudioInputPath || videoInputPath, mainProbe)
const introOutroProbe = await ffprobePromise(introOutroPath) const introOutroProbe = await ffprobePromise(introOutroPath)
const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe) const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
const command = this.commandWrapper.buildCommand(inputPath) const command = this.commandWrapper.buildCommand([ ...this.buildInputs(options), introOutroPath ], inputFileMutexReleaser)
.output(outputPath) .output(outputPath)
command.input(introOutroPath)
if (!introOutroHasAudio && mainHasAudio) { if (!introOutroHasAudio && mainHasAudio) {
const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe) const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
@ -134,8 +145,12 @@ export class FFmpegEdition {
await presetVOD({ await presetVOD({
commandWrapper: this.commandWrapper, commandWrapper: this.commandWrapper,
input: inputPath,
videoInputPath,
separatedAudioInputPath,
resolution, resolution,
videoStreamOnly: false,
fps, fps,
canCopyAudio: false, canCopyAudio: false,
canCopyVideo: false canCopyVideo: false
@ -236,4 +251,11 @@ export class FFmpegEdition {
await this.commandWrapper.runCommand() await this.commandWrapper.runCommand()
} }
private buildInputs (options: {
videoInputPath: string
separatedAudioInputPath?: string
}) {
return [ options.videoInputPath, options.separatedAudioInputPath ].filter(i => !!i)
}
} }

View File

@ -1,10 +1,35 @@
import { pick } from '@peertube/peertube-core-utils' import { pick } from '@peertube/peertube-core-utils'
import { FfprobeData, FilterSpecification } from 'fluent-ffmpeg' import { VideoResolution } from '@peertube/peertube-models'
import { FfmpegCommand, FfprobeData, FilterSpecification } from 'fluent-ffmpeg'
import { join } from 'path' import { join } from 'path'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
import { StreamType, buildStreamSuffix, getScaleFilter } from './ffmpeg-utils.js' import { StreamType, buildStreamSuffix, getScaleFilter } from './ffmpeg-utils.js'
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js' import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js'
type LiveTranscodingOptions = {
inputUrl: string
outPath: string
masterPlaylistName: string
toTranscode: {
resolution: number
fps: number
}[]
// Input information
bitrate: number
ratio: number
hasAudio: boolean
hasVideo: boolean
probe: FfprobeData
segmentListSize: number
segmentDuration: number
splitAudioAndVideo: boolean
}
export class FFmpegLive { export class FFmpegLive {
private readonly commandWrapper: FFmpegCommandWrapper private readonly commandWrapper: FFmpegCommandWrapper
@ -12,132 +37,84 @@ export class FFmpegLive {
this.commandWrapper = new FFmpegCommandWrapper(options) this.commandWrapper = new FFmpegCommandWrapper(options)
} }
async getLiveTranscodingCommand (options: { async getLiveTranscodingCommand (options: LiveTranscodingOptions) {
inputUrl: string this.commandWrapper.debugLog('Building live transcoding command', options)
outPath: string
masterPlaylistName: string
toTranscode: {
resolution: number
fps: number
}[]
// Input information
bitrate: number
ratio: number
hasAudio: boolean
probe: FfprobeData
segmentListSize: number
segmentDuration: number
}) {
const { const {
inputUrl, inputUrl,
outPath, outPath,
toTranscode, toTranscode,
bitrate,
masterPlaylistName, masterPlaylistName,
ratio,
hasAudio, hasAudio,
probe splitAudioAndVideo
} = options } = options
const command = this.commandWrapper.buildCommand(inputUrl) const command = this.commandWrapper.buildCommand(inputUrl)
const varStreamMap: string[] = [] let varStreamMap: string[] = []
const complexFilter: FilterSpecification[] = [
{
inputs: '[v:0]',
filter: 'split',
options: toTranscode.length,
outputs: toTranscode.map(t => `vtemp${t.resolution}`)
}
]
command.outputOption('-sc_threshold 0') command.outputOption('-sc_threshold 0')
addDefaultEncoderGlobalParams(command) addDefaultEncoderGlobalParams(command)
for (let i = 0; i < toTranscode.length; i++) { // Audio input only or audio output only
const streamMap: string[] = [] if (this.isAudioInputOrOutputOnly(options)) {
const { resolution, fps } = toTranscode[i] const result = await this.buildTranscodingStream({
...options,
const baseEncoderBuilderParams = { command,
input: inputUrl, resolution: toTranscode[0].resolution,
fps: toTranscode[0].fps,
streamNum: 0,
// No need to add complexity to the m3u8 playlist, we just provide 1 audio variant stream
splitAudioAndVideo: false,
streamType: 'audio'
})
canCopyAudio: true, varStreamMap = varStreamMap.concat(result.varStreamMap)
canCopyVideo: true, varStreamMap.push(result.streamMap.join(','))
} else {
// Do not mix video with audio only playlist
// Audio only input/output is already taken into account above
const toTranscodeWithoutAudioOnly = toTranscode.filter(t => t.resolution !== VideoResolution.H_NOVIDEO)
inputBitrate: bitrate, let complexFilter: FilterSpecification[] = [
inputRatio: ratio, {
inputProbe: probe, inputs: '[v:0]',
filter: 'split',
options: toTranscodeWithoutAudioOnly.length,
outputs: toTranscodeWithoutAudioOnly.map(t => `vtemp${t.resolution}`)
}
]
resolution, let alreadyProcessedAudio = false
fps,
streamNum: i, for (let i = 0; i < toTranscodeWithoutAudioOnly.length; i++) {
videoType: 'live' as 'live' let streamMap: string[] = []
}
{ const { resolution, fps } = toTranscodeWithoutAudioOnly[i]
const streamType: StreamType = 'video'
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) for (const streamType of [ 'audio' as 'audio', 'video' as 'video' ]) {
if (!builderResult) { if (streamType === 'audio') {
throw new Error('No available live video encoder found') if (!hasAudio || (splitAudioAndVideo && alreadyProcessedAudio)) continue
alreadyProcessedAudio = true
}
const result = await this.buildTranscodingStream({ ...options, command, resolution, fps, streamNum: i, streamType })
varStreamMap = varStreamMap.concat(result.varStreamMap)
streamMap = streamMap.concat(result.streamMap)
complexFilter = complexFilter.concat(result.complexFilter)
} }
command.outputOption(`-map [vout${resolution}]`) if (streamMap.length !== 0) {
varStreamMap.push(streamMap.join(','))
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
this.commandWrapper.debugLog(
`Apply ffmpeg live video params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
{ builderResult, fps, toTranscode }
)
command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
applyEncoderOptions(command, builderResult.result)
complexFilter.push({
inputs: `vtemp${resolution}`,
filter: getScaleFilter(builderResult.result),
options: `w=-2:h=${resolution}`,
outputs: `vout${resolution}`
})
streamMap.push(`v:${i}`)
}
if (hasAudio) {
const streamType: StreamType = 'audio'
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
if (!builderResult) {
throw new Error('No available live audio encoder found')
} }
command.outputOption('-map a:0')
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
this.commandWrapper.debugLog(
`Apply ffmpeg live audio params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
{ builderResult, fps, resolution }
)
command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
applyEncoderOptions(command, builderResult.result)
streamMap.push(`a:${i}`)
} }
varStreamMap.push(streamMap.join(',')) command.complexFilter(complexFilter)
} }
command.complexFilter(complexFilter)
this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName }) this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
command.outputOption('-var_stream_map', varStreamMap.join(' ')) command.outputOption('-var_stream_map', varStreamMap.join(' '))
@ -145,6 +122,101 @@ export class FFmpegLive {
return command return command
} }
private isAudioInputOrOutputOnly (options: Pick<LiveTranscodingOptions, 'hasAudio' | 'hasVideo' | 'toTranscode'>) {
const { hasAudio, hasVideo, toTranscode } = options
if (hasAudio && !hasVideo) return true
if (toTranscode.length === 1 && toTranscode[0].resolution === VideoResolution.H_NOVIDEO) return true
return false
}
private async buildTranscodingStream (
options: Pick<LiveTranscodingOptions, 'inputUrl' | 'bitrate' | 'ratio' | 'probe' | 'hasAudio' | 'splitAudioAndVideo'> & {
command: FfmpegCommand
resolution: number
fps: number
streamNum: number
streamType: StreamType
}
) {
const { inputUrl, bitrate, ratio, probe, splitAudioAndVideo, command, resolution, fps, streamNum, streamType, hasAudio } = options
const baseEncoderBuilderParams = {
input: inputUrl,
canCopyAudio: true,
canCopyVideo: true,
inputBitrate: bitrate,
inputRatio: ratio,
inputProbe: probe,
resolution,
fps,
streamNum,
videoType: 'live' as 'live'
}
const streamMap: string[] = []
const varStreamMap: string[] = []
const complexFilter: FilterSpecification[] = []
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
if (!builderResult) {
throw new Error(`No available live ${streamType} encoder found`)
}
if (streamType === 'audio') {
command.outputOption('-map a:0')
} else {
command.outputOption(`-map [vout${resolution}]`)
}
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum })
this.commandWrapper.debugLog(
`Apply ffmpeg live ${streamType} params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
{ builderResult, fps, resolution }
)
if (streamType === 'audio') {
command.outputOption(`${buildStreamSuffix('-c:a', streamNum)} ${builderResult.encoder}`)
if (splitAudioAndVideo) {
varStreamMap.push(`a:${streamNum},agroup:Audio,default:yes`)
} else {
streamMap.push(`a:${streamNum}`)
}
} else {
command.outputOption(`${buildStreamSuffix('-c:v', streamNum)} ${builderResult.encoder}`)
complexFilter.push({
inputs: `vtemp${resolution}`,
filter: getScaleFilter(builderResult.result),
options: `w=-2:h=${resolution}`,
outputs: `vout${resolution}`
})
if (splitAudioAndVideo) {
const suffix = hasAudio
? `,agroup:Audio`
: ''
varStreamMap.push(`v:${streamNum}${suffix}`)
} else {
streamMap.push(`v:${streamNum}`)
}
}
applyEncoderOptions(command, builderResult.result)
return { varStreamMap, streamMap, complexFilter }
}
// ---------------------------------------------------------------------------
getLiveMuxingCommand (options: { getLiveMuxingCommand (options: {
inputUrl: string inputUrl: string
outPath: string outPath: string
@ -167,6 +239,8 @@ export class FFmpegLive {
return command return command
} }
// ---------------------------------------------------------------------------
private addDefaultLiveHLSParams (options: { private addDefaultLiveHLSParams (options: {
outPath: string outPath: string
masterPlaylistName: string masterPlaylistName: string

View File

@ -1,19 +1,20 @@
import { pick } from '@peertube/peertube-core-utils' import { pick } from '@peertube/peertube-core-utils'
import { VideoResolution } from '@peertube/peertube-models'
import { MutexInterface } from 'async-mutex' import { MutexInterface } from 'async-mutex'
import { FfmpegCommand } from 'fluent-ffmpeg' import { FfmpegCommand } from 'fluent-ffmpeg'
import { readFile, writeFile } from 'fs/promises' import { readFile, writeFile } from 'fs/promises'
import { dirname } from 'path' import { dirname } from 'path'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js' import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js'
import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js' import { presetCopy, presetVOD } from './shared/presets.js'
export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio'
export interface BaseTranscodeVODOptions { export interface BaseTranscodeVODOptions {
type: TranscodeVODOptionsType type: TranscodeVODOptionsType
inputPath: string videoInputPath: string
separatedAudioInputPath?: string
outputPath: string outputPath: string
// Will be released after the ffmpeg started // Will be released after the ffmpeg started
@ -28,6 +29,7 @@ export interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
type: 'hls' type: 'hls'
copyCodecs: boolean copyCodecs: boolean
separatedAudio: boolean
hlsPlaylist: { hlsPlaylist: {
videoFilename: string videoFilename: string
@ -83,12 +85,14 @@ export class FFmpegVOD {
'hls': this.buildHLSVODCommand.bind(this), 'hls': this.buildHLSVODCommand.bind(this),
'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this), 'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
'merge-audio': this.buildAudioMergeCommand.bind(this), 'merge-audio': this.buildAudioMergeCommand.bind(this),
'video': this.buildWebVideoCommand.bind(this) 'video': this.buildVODCommand.bind(this)
} }
this.commandWrapper.debugLog('Will run transcode.', { options }) this.commandWrapper.debugLog('Will run transcode.', { options })
this.commandWrapper.buildCommand(options.inputPath, options.inputFileMutexReleaser) const inputPaths = [ options.videoInputPath, options.separatedAudioInputPath ].filter(e => !!e)
this.commandWrapper.buildCommand(inputPaths, options.inputFileMutexReleaser)
.output(options.outputPath) .output(options.outputPath)
await builders[options.type](options) await builders[options.type](options)
@ -104,19 +108,26 @@ export class FFmpegVOD {
return this.ended return this.ended
} }
private async buildWebVideoCommand (options: TranscodeVODOptions & { canCopyAudio?: boolean, canCopyVideo?: boolean }) { private async buildVODCommand (options: TranscodeVODOptions & {
const { resolution, fps, inputPath, canCopyAudio = true, canCopyVideo = true } = options videoStreamOnly?: boolean
canCopyAudio?: boolean
if (resolution === VideoResolution.H_NOVIDEO) { canCopyVideo?: boolean
presetOnlyAudio(this.commandWrapper) }) {
return const {
} resolution,
fps,
videoInputPath,
separatedAudioInputPath,
videoStreamOnly = false,
canCopyAudio = true,
canCopyVideo = true
} = options
let scaleFilterValue: string let scaleFilterValue: string
if (resolution !== undefined) { if (resolution) {
const probe = await ffprobePromise(inputPath) const probe = await ffprobePromise(videoInputPath)
const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe) const videoStreamInfo = await getVideoStreamDimensionsInfo(videoInputPath, probe)
scaleFilterValue = videoStreamInfo?.isPortraitMode === true scaleFilterValue = videoStreamInfo?.isPortraitMode === true
? `w=${resolution}:h=-2` ? `w=${resolution}:h=-2`
@ -127,7 +138,11 @@ export class FFmpegVOD {
commandWrapper: this.commandWrapper, commandWrapper: this.commandWrapper,
resolution, resolution,
input: inputPath, videoStreamOnly,
videoInputPath,
separatedAudioInputPath,
canCopyAudio, canCopyAudio,
canCopyVideo, canCopyVideo,
fps, fps,
@ -157,9 +172,10 @@ export class FFmpegVOD {
...pick(options, [ 'resolution' ]), ...pick(options, [ 'resolution' ]),
commandWrapper: this.commandWrapper, commandWrapper: this.commandWrapper,
input: options.audioPath, videoInputPath: options.audioPath,
canCopyAudio: true, canCopyAudio: true,
canCopyVideo: true, canCopyVideo: true,
videoStreamOnly: false,
fps: options.fps, fps: options.fps,
scaleFilterValue: this.getMergeAudioScaleFilterValue() scaleFilterValue: this.getMergeAudioScaleFilterValue()
}) })
@ -186,13 +202,16 @@ export class FFmpegVOD {
const videoPath = this.getHLSVideoPath(options) const videoPath = this.getHLSVideoPath(options)
if (options.copyCodecs) { if (options.copyCodecs) {
presetCopy(this.commandWrapper) presetCopy(this.commandWrapper, {
} else if (options.resolution === VideoResolution.H_NOVIDEO) { withAudio: !options.separatedAudio || !options.resolution,
presetOnlyAudio(this.commandWrapper) withVideo: !options.separatedAudio || !!options.resolution
})
} else { } else {
// If we cannot copy codecs, we do not copy them at all to prevent issues like audio desync await this.buildVODCommand({
// See for example https://github.com/Chocobozzz/PeerTube/issues/6438 ...options,
await this.buildWebVideoCommand({ ...options, canCopyAudio: false, canCopyVideo: false })
videoStreamOnly: options.separatedAudio && !!options.resolution
})
} }
this.addCommonHLSVODCommandOptions(command, videoPath) this.addCommonHLSVODCommandOptions(command, videoPath)

View File

@ -174,6 +174,12 @@ async function getVideoStream (path: string, existingProbe?: FfprobeData) {
return metadata.streams.find(s => s.codec_type === 'video') return metadata.streams.find(s => s.codec_type === 'video')
} }
async function hasVideoStream (path: string, existingProbe?: FfprobeData) {
const videoStream = await getVideoStream(path, existingProbe)
return !!videoStream
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Chapters // Chapters
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -209,5 +215,6 @@ export {
isAudioFile, isAudioFile,
ffprobePromise, ffprobePromise,
getVideoStreamBitrate, getVideoStreamBitrate,
hasAudioStream hasAudioStream,
hasVideoStream
} }

View File

@ -1,4 +1,5 @@
export * from './ffmpeg-command-wrapper.js' export * from './ffmpeg-command-wrapper.js'
export * from './ffmpeg-container.js'
export * from './ffmpeg-default-transcoding-profile.js' export * from './ffmpeg-default-transcoding-profile.js'
export * from './ffmpeg-edition.js' export * from './ffmpeg-edition.js'
export * from './ffmpeg-images.js' export * from './ffmpeg-images.js'

View File

@ -7,7 +7,8 @@ import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOpt
export async function presetVOD (options: { export async function presetVOD (options: {
commandWrapper: FFmpegCommandWrapper commandWrapper: FFmpegCommandWrapper
input: string videoInputPath: string
separatedAudioInputPath?: string
canCopyAudio: boolean canCopyAudio: boolean
canCopyVideo: boolean canCopyVideo: boolean
@ -15,9 +16,16 @@ export async function presetVOD (options: {
resolution: number resolution: number
fps: number fps: number
videoStreamOnly: boolean
scaleFilterValue?: string scaleFilterValue?: string
}) { }) {
const { commandWrapper, input, resolution, fps, scaleFilterValue } = options const { commandWrapper, videoInputPath, separatedAudioInputPath, resolution, fps, videoStreamOnly, scaleFilterValue } = options
if (videoStreamOnly && !resolution) {
throw new Error('Cannot generate video stream only without valid resolution')
}
const command = commandWrapper.getCommand() const command = commandWrapper.getCommand()
command.format('mp4') command.format('mp4')
@ -25,27 +33,40 @@ export async function presetVOD (options: {
addDefaultEncoderGlobalParams(command) addDefaultEncoderGlobalParams(command)
const probe = await ffprobePromise(input) const videoProbe = await ffprobePromise(videoInputPath)
const audioProbe = separatedAudioInputPath
? await ffprobePromise(separatedAudioInputPath)
: videoProbe
// Audio encoder // Audio encoder
const bitrate = await getVideoStreamBitrate(input, probe) const bitrate = await getVideoStreamBitrate(videoInputPath, videoProbe)
const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe) const videoStreamDimensions = await getVideoStreamDimensionsInfo(videoInputPath, videoProbe)
let streamsToProcess: StreamType[] = [ 'audio', 'video' ] let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
if (!await hasAudioStream(input, probe)) { if (videoStreamOnly || !await hasAudioStream(separatedAudioInputPath || videoInputPath, audioProbe)) {
command.noAudio() command.noAudio()
streamsToProcess = [ 'video' ] streamsToProcess = [ 'video' ]
} else if (!resolution) {
command.noVideo()
streamsToProcess = [ 'audio' ]
} }
for (const streamType of streamsToProcess) { for (const streamType of streamsToProcess) {
const input = streamType === 'video'
? videoInputPath
: separatedAudioInputPath || videoInputPath
const builderResult = await commandWrapper.getEncoderBuilderResult({ const builderResult = await commandWrapper.getEncoderBuilderResult({
...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]), ...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]),
input, input,
inputProbe: streamType === 'video'
? videoProbe
: audioProbe,
inputBitrate: bitrate, inputBitrate: bitrate,
inputRatio: videoStreamDimensions?.ratio || 0, inputRatio: videoStreamDimensions?.ratio || 0,
inputProbe: probe,
resolution, resolution,
fps, fps,
@ -79,16 +100,17 @@ export async function presetVOD (options: {
} }
} }
export function presetCopy (commandWrapper: FFmpegCommandWrapper) { export function presetCopy (commandWrapper: FFmpegCommandWrapper, options: {
commandWrapper.getCommand() withAudio?: boolean // default true
.format('mp4') withVideo?: boolean // default true
.videoCodec('copy') } = {}) {
.audioCodec('copy') const command = commandWrapper.getCommand()
}
export function presetOnlyAudio (commandWrapper: FFmpegCommandWrapper) { command.format('mp4')
commandWrapper.getCommand()
.format('mp4') if (options.withAudio === false) command.noAudio()
.audioCodec('copy') else command.audioCodec('copy')
.noVideo()
if (options.withVideo === false) command.noVideo()
else command.videoCodec('copy')
} }

View File

@ -14,6 +14,18 @@ export interface ActivityIconObject {
height: number | null height: number | null
} }
// ---------------------------------------------------------------------------
export type ActivityVideoUrlObjectAttachment = {
type: 'PropertyValue'
name: 'ffprobe_codec_type'
value: 'video' | 'audio'
} | {
type: 'PropertyValue'
name: 'peertube_format_flag'
value: 'web-video' | 'fragmented'
}
export type ActivityVideoUrlObject = { export type ActivityVideoUrlObject = {
type: 'Link' type: 'Link'
mediaType: 'video/mp4' | 'video/webm' | 'video/ogg' | 'audio/mp4' mediaType: 'video/mp4' | 'video/webm' | 'video/ogg' | 'audio/mp4'
@ -22,8 +34,12 @@ export type ActivityVideoUrlObject = {
width: number | null width: number | null
size: number size: number
fps: number fps: number
attachment: ActivityVideoUrlObjectAttachment[]
} }
// ---------------------------------------------------------------------------
export type ActivityPlaylistSegmentHashesObject = { export type ActivityPlaylistSegmentHashesObject = {
type: 'Link' type: 'Link'
name: 'sha256' name: 'sha256'

View File

@ -106,6 +106,7 @@ export const serverFilterHookObject = {
// Filter result used to check if video/torrent download is allowed // Filter result used to check if video/torrent download is allowed
'filter:api.download.video.allowed.result': true, 'filter:api.download.video.allowed.result': true,
'filter:api.download.generated-video.allowed.result': true,
'filter:api.download.torrent.allowed.result': true, 'filter:api.download.torrent.allowed.result': true,
// Filter result to check if the embed is allowed for a particular request // Filter result to check if the embed is allowed for a particular request

View File

@ -16,6 +16,7 @@ export type RunnerJobPayload =
export interface RunnerJobVODWebVideoTranscodingPayload { export interface RunnerJobVODWebVideoTranscodingPayload {
input: { input: {
videoFileUrl: string videoFileUrl: string
separatedAudioFileUrl: string[]
} }
output: { output: {
@ -27,11 +28,13 @@ export interface RunnerJobVODWebVideoTranscodingPayload {
export interface RunnerJobVODHLSTranscodingPayload { export interface RunnerJobVODHLSTranscodingPayload {
input: { input: {
videoFileUrl: string videoFileUrl: string
separatedAudioFileUrl: string[]
} }
output: { output: {
resolution: number resolution: number
fps: number fps: number
separatedAudio: boolean
} }
} }
@ -50,6 +53,7 @@ export interface RunnerJobVODAudioMergeTranscodingPayload {
export interface RunnerJobStudioTranscodingPayload { export interface RunnerJobStudioTranscodingPayload {
input: { input: {
videoFileUrl: string videoFileUrl: string
separatedAudioFileUrl: string[]
} }
tasks: VideoStudioTaskPayload[] tasks: VideoStudioTaskPayload[]

View File

@ -2,6 +2,7 @@ import { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
import { BroadcastMessageLevel } from './broadcast-message-level.type.js' import { BroadcastMessageLevel } from './broadcast-message-level.type.js'
export type ConfigResolutions = { export type ConfigResolutions = {
'0p': boolean
'144p': boolean '144p': boolean
'240p': boolean '240p': boolean
'360p': boolean '360p': boolean
@ -133,7 +134,7 @@ export interface CustomConfig {
profile: string profile: string
resolutions: ConfigResolutions & { '0p': boolean } resolutions: ConfigResolutions
alwaysTranscodeOriginalResolution: boolean alwaysTranscodeOriginalResolution: boolean
@ -143,6 +144,7 @@ export interface CustomConfig {
hls: { hls: {
enabled: boolean enabled: boolean
splitAudioAndVideo: boolean
} }
} }

View File

@ -147,6 +147,7 @@ export type ManageVideoTorrentPayload =
interface BaseTranscodingPayload { interface BaseTranscodingPayload {
videoUUID: string videoUUID: string
hasChildren?: boolean
isNewVideo?: boolean isNewVideo?: boolean
} }
@ -156,6 +157,8 @@ export interface HLSTranscodingPayload extends BaseTranscodingPayload {
fps: number fps: number
copyCodecs: boolean copyCodecs: boolean
separatedAudio: boolean
deleteWebVideoFiles: boolean deleteWebVideoFiles: boolean
} }
@ -170,16 +173,12 @@ export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload {
resolution: number resolution: number
fps: number fps: number
hasChildren: boolean
} }
export interface OptimizeTranscodingPayload extends BaseTranscodingPayload { export interface OptimizeTranscodingPayload extends BaseTranscodingPayload {
type: 'optimize-to-web-video' type: 'optimize-to-web-video'
quickTranscode: boolean quickTranscode: boolean
hasChildren: boolean
} }
export type VideoTranscodingPayload = export type VideoTranscodingPayload =

View File

@ -1,3 +1,5 @@
export * from './video-file-metadata.model.js' export * from './video-file-metadata.model.js'
export * from './video-file.model.js' export * from './video-file.model.js'
export * from './video-resolution.enum.js' export * from './video-resolution.enum.js'
export * from './video-file-format-flag.enum.js'
export * from './video-file-stream.enum.js'

View File

@ -0,0 +1,7 @@
export const VideoFileFormatFlag = {
NONE: 0,
WEB_VIDEO: 1 << 0,
FRAGMENTED: 1 << 1
} as const
export type VideoFileFormatFlagType = typeof VideoFileFormatFlag[keyof typeof VideoFileFormatFlag]

View File

@ -0,0 +1,7 @@
export const VideoFileStream = {
NONE: 0,
VIDEO: 1 << 0,
AUDIO: 1 << 1
} as const
export type VideoFileStreamType = typeof VideoFileStream[keyof typeof VideoFileStream]

View File

@ -22,4 +22,7 @@ export interface VideoFile {
metadataUrl?: string metadataUrl?: string
magnetUri: string | null magnetUri: string | null
hasAudio: boolean
hasVideo: boolean
} }

View File

@ -5,7 +5,7 @@ import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-comm
export class ConfigCommand extends AbstractCommand { export class ConfigCommand extends AbstractCommand {
static getCustomConfigResolutions (enabled: boolean, with0p = false) { static getConfigResolutions (enabled: boolean, with0p = false) {
return { return {
'0p': enabled && with0p, '0p': enabled && with0p,
'144p': enabled, '144p': enabled,
@ -19,6 +19,20 @@ export class ConfigCommand extends AbstractCommand {
} }
} }
static getCustomConfigResolutions (enabled: number[]) {
return {
'0p': enabled.includes(0),
'144p': enabled.includes(144),
'240p': enabled.includes(240),
'360p': enabled.includes(360),
'480p': enabled.includes(480),
'720p': enabled.includes(720),
'1080p': enabled.includes(1080),
'1440p': enabled.includes(1440),
'2160p': enabled.includes(2160)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static getEmailOverrideConfig (emailPort: number) { static getEmailOverrideConfig (emailPort: number) {
@ -211,19 +225,27 @@ export class ConfigCommand extends AbstractCommand {
enableLive (options: { enableLive (options: {
allowReplay?: boolean allowReplay?: boolean
resolutions?: 'min' | 'max' | number[] // default 'min'
transcoding?: boolean transcoding?: boolean
resolutions?: 'min' | 'max' // Default max maxDuration?: number
alwaysTranscodeOriginalResolution?: boolean
} = {}) { } = {}) {
const { allowReplay, transcoding, resolutions = 'max' } = options const { allowReplay, transcoding, maxDuration, resolutions = 'min', alwaysTranscodeOriginalResolution } = options
return this.updateExistingConfig({ return this.updateExistingConfig({
newConfig: { newConfig: {
live: { live: {
enabled: true, enabled: true,
allowReplay: allowReplay ?? true, allowReplay,
maxDuration,
transcoding: { transcoding: {
enabled: transcoding ?? true, enabled: transcoding,
resolutions: ConfigCommand.getCustomConfigResolutions(resolutions === 'max')
alwaysTranscodeOriginalResolution,
resolutions: Array.isArray(resolutions)
? ConfigCommand.getCustomConfigResolutions(resolutions)
: ConfigCommand.getConfigResolutions(resolutions === 'max')
} }
} }
} }
@ -246,10 +268,14 @@ export class ConfigCommand extends AbstractCommand {
enableTranscoding (options: { enableTranscoding (options: {
webVideo?: boolean // default true webVideo?: boolean // default true
hls?: boolean // default true hls?: boolean // default true
with0p?: boolean // default false
keepOriginal?: boolean // default false keepOriginal?: boolean // default false
splitAudioAndVideo?: boolean // default false
resolutions?: 'min' | 'max' | number[] // default 'max'
with0p?: boolean // default false
} = {}) { } = {}) {
const { webVideo = true, hls = true, with0p = false, keepOriginal = false } = options const { resolutions = 'max', webVideo = true, hls = true, with0p = false, keepOriginal = false, splitAudioAndVideo = false } = options
return this.updateExistingConfig({ return this.updateExistingConfig({
newConfig: { newConfig: {
@ -262,25 +288,39 @@ export class ConfigCommand extends AbstractCommand {
allowAudioFiles: true, allowAudioFiles: true,
allowAdditionalExtensions: true, allowAdditionalExtensions: true,
resolutions: ConfigCommand.getCustomConfigResolutions(true, with0p), resolutions: Array.isArray(resolutions)
? ConfigCommand.getCustomConfigResolutions(resolutions)
: ConfigCommand.getConfigResolutions(resolutions === 'max', with0p),
webVideos: { webVideos: {
enabled: webVideo enabled: webVideo
}, },
hls: { hls: {
enabled: hls enabled: hls,
splitAudioAndVideo
} }
} }
} }
}) })
} }
setTranscodingConcurrency (concurrency: number) {
return this.updateExistingConfig({
newConfig: {
transcoding: {
concurrency
}
}
})
}
enableMinimumTranscoding (options: { enableMinimumTranscoding (options: {
webVideo?: boolean // default true webVideo?: boolean // default true
hls?: boolean // default true hls?: boolean // default true
splitAudioAndVideo?: boolean // default false
keepOriginal?: boolean // default false keepOriginal?: boolean // default false
} = {}) { } = {}) {
const { webVideo = true, hls = true, keepOriginal = false } = options const { webVideo = true, hls = true, keepOriginal = false, splitAudioAndVideo = false } = options
return this.updateExistingConfig({ return this.updateExistingConfig({
newConfig: { newConfig: {
@ -294,7 +334,7 @@ export class ConfigCommand extends AbstractCommand {
allowAdditionalExtensions: true, allowAdditionalExtensions: true,
resolutions: { resolutions: {
...ConfigCommand.getCustomConfigResolutions(false), ...ConfigCommand.getConfigResolutions(false),
'240p': true '240p': true
}, },
@ -303,7 +343,8 @@ export class ConfigCommand extends AbstractCommand {
enabled: webVideo enabled: webVideo
}, },
hls: { hls: {
enabled: hls enabled: hls,
splitAudioAndVideo
} }
} }
} }

View File

@ -1,7 +1,7 @@
import { waitJobs } from './jobs.js' import { waitJobs } from './jobs.js'
import { PeerTubeServer } from './server.js' import { PeerTubeServer } from './server.js'
async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) { export async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
await Promise.all([ await Promise.all([
server1.follows.follow({ hosts: [ server2.url ] }), server1.follows.follow({ hosts: [ server2.url ] }),
server2.follows.follow({ hosts: [ server1.url ] }) server2.follows.follow({ hosts: [ server1.url ] })
@ -9,12 +9,18 @@ async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
// Wait request propagation // Wait request propagation
await waitJobs([ server1, server2 ]) await waitJobs([ server1, server2 ])
return true
} }
// --------------------------------------------------------------------------- export function followAll (servers: PeerTubeServer[]) {
const p: Promise<void>[] = []
export { for (const server of servers) {
doubleFollow for (const remoteServer of servers) {
if (server === remoteServer) continue
p.push(doubleFollow(server, remoteServer))
}
}
return Promise.all(p)
} }

View File

@ -29,7 +29,7 @@ async function waitJobs (
// Check if each server has pending request // Check if each server has pending request
for (const server of servers) { for (const server of servers) {
if (process.env.DEBUG) console.log('Checking ' + server.url) if (process.env.DEBUG) console.log(`${new Date().toISOString()} - Checking ${server.url}`)
for (const state of states) { for (const state of states) {
@ -45,7 +45,7 @@ async function waitJobs (
pendingRequests = true pendingRequests = true
if (process.env.DEBUG) { if (process.env.DEBUG) {
console.log(jobs) console.log(`${new Date().toISOString()}`, jobs)
} }
} }
}) })
@ -59,7 +59,7 @@ async function waitJobs (
pendingRequests = true pendingRequests = true
if (process.env.DEBUG) { if (process.env.DEBUG) {
console.log('AP messages waiting: ' + obj.activityPubMessagesWaiting) console.log(`${new Date().toISOString()} - AP messages waiting: ${obj.activityPubMessagesWaiting}`)
} }
} }
}) })
@ -73,7 +73,7 @@ async function waitJobs (
pendingRequests = true pendingRequests = true
if (process.env.DEBUG) { if (process.env.DEBUG) {
console.log(job) console.log(`${new Date().toISOString()}`, job)
} }
} }
} }

View File

@ -4,7 +4,7 @@ import { PeerTubeServer } from '../server/server.js'
export async function setDefaultAccountAvatar (serversArg: PeerTubeServer | PeerTubeServer[], token?: string) { export async function setDefaultAccountAvatar (serversArg: PeerTubeServer | PeerTubeServer[], token?: string) {
const servers = arrayify(serversArg) const servers = arrayify(serversArg)
for (const server of servers) { return Promise.all(
await server.users.updateMyAvatar({ fixture: 'avatar.png', token }) servers.map(s => s.users.updateMyAvatar({ fixture: 'avatar.png', token }))
} )
} }

View File

@ -2,22 +2,18 @@ import { arrayify } from '@peertube/peertube-core-utils'
import { PeerTubeServer } from '../server/server.js' import { PeerTubeServer } from '../server/server.js'
export function setDefaultVideoChannel (servers: PeerTubeServer[]) { export function setDefaultVideoChannel (servers: PeerTubeServer[]) {
const tasks: Promise<any>[] = [] return Promise.all(
servers.map(s => {
for (const server of servers) { return s.users.getMyInfo()
const p = server.users.getMyInfo() .then(user => { s.store.channel = user.videoChannels[0] })
.then(user => { server.store.channel = user.videoChannels[0] }) })
)
tasks.push(p)
}
return Promise.all(tasks)
} }
export async function setDefaultChannelAvatar (serversArg: PeerTubeServer | PeerTubeServer[], channelName: string = 'root_channel') { export async function setDefaultChannelAvatar (serversArg: PeerTubeServer | PeerTubeServer[], channelName: string = 'root_channel') {
const servers = arrayify(serversArg) const servers = arrayify(serversArg)
for (const server of servers) { return Promise.all(
await server.channels.updateImage({ channelName, fixture: 'avatar.png', type: 'avatar' }) servers.map(s => s.channels.updateImage({ channelName, fixture: 'avatar.png', type: 'avatar' }))
} )
} }

View File

@ -167,6 +167,7 @@ export class LiveCommand extends AbstractCommand {
async runAndTestStreamError (options: OverrideCommandOptions & { async runAndTestStreamError (options: OverrideCommandOptions & {
videoId: number | string videoId: number | string
shouldHaveError: boolean shouldHaveError: boolean
fixtureName?: string
}) { }) {
const command = await this.sendRTMPStreamInVideo(options) const command = await this.sendRTMPStreamInVideo(options)

View File

@ -5,7 +5,7 @@ import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
import truncate from 'lodash-es/truncate.js' import truncate from 'lodash-es/truncate.js'
import { PeerTubeServer } from '../server/server.js' import { PeerTubeServer } from '../server/server.js'
function sendRTMPStream (options: { export function sendRTMPStream (options: {
rtmpBaseUrl: string rtmpBaseUrl: string
streamKey: string streamKey: string
fixtureName?: string // default video_short.mp4 fixtureName?: string // default video_short.mp4
@ -49,7 +49,7 @@ function sendRTMPStream (options: {
return command return command
} }
function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) { export function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) {
return new Promise<void>((res, rej) => { return new Promise<void>((res, rej) => {
command.on('error', err => { command.on('error', err => {
return rej(err) return rej(err)
@ -61,7 +61,7 @@ function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) {
}) })
} }
async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) { export async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) {
let error: Error let error: Error
try { try {
@ -76,31 +76,39 @@ async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: b
if (!shouldHaveError && error) throw error if (!shouldHaveError && error) throw error
} }
async function stopFfmpeg (command: FfmpegCommand) { export async function stopFfmpeg (command: FfmpegCommand) {
command.kill('SIGINT') command.kill('SIGINT')
await wait(500) await wait(500)
} }
async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) { export async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) {
for (const server of servers) { for (const server of servers) {
await server.live.waitUntilPublished({ videoId }) await server.live.waitUntilPublished({ videoId })
} }
} }
async function waitUntilLiveWaitingOnAllServers (servers: PeerTubeServer[], videoId: string) { export async function waitUntilLiveWaitingOnAllServers (servers: PeerTubeServer[], videoId: string) {
for (const server of servers) { for (const server of servers) {
await server.live.waitUntilWaiting({ videoId }) await server.live.waitUntilWaiting({ videoId })
} }
} }
async function waitUntilLiveReplacedByReplayOnAllServers (servers: PeerTubeServer[], videoId: string) { export async function waitUntilLiveReplacedByReplayOnAllServers (servers: PeerTubeServer[], videoId: string) {
for (const server of servers) { for (const server of servers) {
await server.live.waitUntilReplacedByReplay({ videoId }) await server.live.waitUntilReplacedByReplay({ videoId })
} }
} }
async function findExternalSavedVideo (server: PeerTubeServer, liveDetails: VideoDetails) { export async function findExternalSavedVideo (server: PeerTubeServer, liveVideoUUID: string) {
let liveDetails: VideoDetails
try {
liveDetails = await server.videos.getWithToken({ id: liveVideoUUID })
} catch {
return undefined
}
const include = VideoInclude.BLACKLISTED const include = VideoInclude.BLACKLISTED
const privacyOneOf = [ VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.PUBLIC, VideoPrivacy.UNLISTED ] const privacyOneOf = [ VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.PUBLIC, VideoPrivacy.UNLISTED ]
@ -114,16 +122,3 @@ async function findExternalSavedVideo (server: PeerTubeServer, liveDetails: Vide
return data.find(v => v.name === toFind) return data.find(v => v.name === toFind)
} }
export {
sendRTMPStream,
waitFfmpegUntilError,
testFfmpegStreamError,
stopFfmpeg,
waitUntilLivePublishedOnAllServers,
waitUntilLiveReplacedByReplayOnAllServers,
waitUntilLiveWaitingOnAllServers,
findExternalSavedVideo
}

View File

@ -341,6 +341,14 @@ export class VideosCommand extends AbstractCommand {
return data.find(v => v.name === options.name) return data.find(v => v.name === options.name)
} }
async findFull (options: OverrideCommandOptions & {
name: string
}) {
const { uuid } = await this.find(options)
return this.get({ id: uuid })
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
update (options: OverrideCommandOptions & { update (options: OverrideCommandOptions & {
@ -662,4 +670,25 @@ export class VideosCommand extends AbstractCommand {
endVideoResumableUpload (options: Parameters<AbstractCommand['endResumableUpload']>[0]) { endVideoResumableUpload (options: Parameters<AbstractCommand['endResumableUpload']>[0]) {
return super.endResumableUpload(options) return super.endResumableUpload(options)
} }
// ---------------------------------------------------------------------------
generateDownload (options: OverrideCommandOptions & {
videoId: number | string
videoFileIds: number[]
query?: Record<string, string>
}) {
const { videoFileIds, videoId, query = {} } = options
const path = '/download/videos/generate/' + videoId
return this.getRequestBody<Buffer>({
...options,
path,
query: { videoFileIds, ...query },
responseType: 'arraybuffer',
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
} }

View File

@ -0,0 +1,136 @@
import { getHLS } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
import {
PeerTubeServer,
cleanupTests,
createSingleServer,
setAccessTokensToServers,
setDefaultVideoChannel,
waitJobs
} from '@peertube/peertube-server-commands'
describe('Test generate download API validator', function () {
let server: PeerTubeServer
before(async function () {
this.timeout(120000)
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
await setDefaultVideoChannel([ server ])
})
describe('Download rights', function () {
let videoFileToken: string
let videoId: string
let videoFileIds: number[]
let user3: string
let user4: string
before(async function () {
this.timeout(60000)
user3 = await server.users.generateUserAndToken('user3')
user4 = await server.users.generateUserAndToken('user4')
const { uuid } = await server.videos.quickUpload({ name: 'video', token: user3, privacy: VideoPrivacy.PRIVATE })
videoId = uuid
videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid, token: user3 })
const video = await server.videos.getWithToken({ id: uuid })
videoFileIds = [ video.files[0].id ]
await waitJobs([ server ])
})
it('Should fail without header token or video file token', async function () {
await server.videos.generateDownload({ videoId, videoFileIds, token: null, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should fail with an invalid header token', async function () {
await server.videos.generateDownload({ videoId, videoFileIds, token: 'toto', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should fail with an invalid video file token', async function () {
const query = { videoFileToken: 'toto' }
await server.videos.generateDownload({ videoId, videoFileIds, token: null, query, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should fail with header token of another user', async function () {
await server.videos.generateDownload({ videoId, videoFileIds, token: user4, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should fail with video file token of another user', async function () {
const { uuid: otherVideo } = await server.videos.quickUpload({ name: 'other video' })
const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: otherVideo, token: user4 })
const query = { videoFileToken }
await server.videos.generateDownload({ videoId, videoFileIds, token: null, query, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should succeed with a valid header token', async function () {
await server.videos.generateDownload({ videoId, videoFileIds, token: user3 })
})
it('Should succeed with a valid query token', async function () {
await server.videos.generateDownload({ videoId, videoFileIds, token: null, query: { videoFileToken } })
})
})
describe('Download params', function () {
let videoId: string
let videoStreamIds: number[]
let audioStreamId: number
before(async function () {
this.timeout(60000)
await server.config.enableMinimumTranscoding({ hls: true, splitAudioAndVideo: true })
const { uuid } = await server.videos.quickUpload({ name: 'video' })
videoId = uuid
await waitJobs([ server ])
const video = await server.videos.get({ id: uuid })
videoStreamIds = getHLS(video).files.filter(f => !f.hasAudio).map(f => f.id)
audioStreamId = getHLS(video).files.find(f => !!f.hasAudio).id
})
it('Should fail with invalid video id', async function () {
await server.videos.generateDownload({ videoId: 42, videoFileIds: [ 41 ], expectedStatus: HttpStatusCode.NOT_FOUND_404 })
})
it('Should fail with invalid videoFileIds query', async function () {
const tests = [
undefined,
[],
[ 40, 41, 42 ]
]
for (const videoFileIds of tests) {
await server.videos.generateDownload({ videoId, videoFileIds, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
}
})
it('Should fail with multiple video files', async function () {
const videoFileIds = videoStreamIds
await server.videos.generateDownload({ videoId, videoFileIds, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should suceed with the correct params', async function () {
const videoFileIds = [ audioStreamId, videoStreamIds[0] ]
await server.videos.generateDownload({ videoId, videoFileIds })
})
})
after(async function () {
await cleanupTests([ server ])
})
})

View File

@ -9,6 +9,7 @@ import './contact-form.js'
import './custom-pages.js' import './custom-pages.js'
import './debug.js' import './debug.js'
import './follows.js' import './follows.js'
import './generate-download.js'
import './jobs.js' import './jobs.js'
import './live.js' import './live.js'
import './logs.js' import './logs.js'

View File

@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { basename } from 'path'
import { import {
HttpStatusCode, HttpStatusCode,
HttpStatusCodeType, HttpStatusCodeType,
@ -12,7 +11,6 @@ import {
VideoPrivacy, VideoPrivacy,
VideoStudioTaskIntro VideoStudioTaskIntro
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
import { import {
cleanupTests, cleanupTests,
createSingleServer, createSingleServer,
@ -25,6 +23,8 @@ import {
VideoStudioCommand, VideoStudioCommand,
waitJobs waitJobs
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
import { basename } from 'path'
const badUUID = '910ec12a-d9e6-458b-a274-0abb655f9464' const badUUID = '910ec12a-d9e6-458b-a274-0abb655f9464'
@ -66,7 +66,7 @@ describe('Test managing runners', function () {
registrationToken = data[0].registrationToken registrationToken = data[0].registrationToken
registrationTokenId = data[0].id registrationTokenId = data[0].id
await server.config.enableTranscoding({ hls: true, webVideo: true }) await server.config.enableTranscoding({ hls: true, webVideo: true, resolutions: 'min' })
await server.config.enableStudio() await server.config.enableStudio()
await server.config.enableRemoteTranscoding() await server.config.enableRemoteTranscoding()
await server.config.enableRemoteStudio() await server.config.enableRemoteStudio()
@ -452,7 +452,7 @@ describe('Test managing runners', function () {
const { uuid } = await server.videos.quickUpload({ name: 'video studio' }) const { uuid } = await server.videos.quickUpload({ name: 'video studio' })
videoStudioUUID = uuid videoStudioUUID = uuid
await server.config.enableTranscoding({ hls: true, webVideo: true }) await server.config.enableTranscoding({ hls: true, webVideo: true, resolutions: 'min' })
await server.config.enableStudio() await server.config.enableStudio()
await server.videoStudio.createEditionTasks({ await server.videoStudio.createEditionTasks({

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { getAllFiles } from '@peertube/peertube-core-utils' import { getAllFiles, getHLS } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRole, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' import { HttpStatusCode, UserRole, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
import { import {
cleanupTests, cleanupTests,
createMultipleServers, createMultipleServers,
@ -73,9 +73,14 @@ describe('Test videos files', function () {
let remoteHLSFileId: number let remoteHLSFileId: number
let remoteWebVideoFileId: number let remoteWebVideoFileId: number
let splittedHLSId: string
let hlsWithAudioId: string
before(async function () { before(async function () {
this.timeout(300_000) this.timeout(300_000)
const resolutions = [ VideoResolution.H_NOVIDEO, VideoResolution.H_144P, VideoResolution.H_240P ]
{ {
const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' }) const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' })
await waitJobs(servers) await waitJobs(servers)
@ -87,7 +92,7 @@ describe('Test videos files', function () {
} }
{ {
await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) await servers[0].config.enableTranscoding({ hls: true, webVideo: true, resolutions })
{ {
const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' }) const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' })
@ -103,22 +108,43 @@ describe('Test videos files', function () {
const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' }) const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' })
validId2 = uuid validId2 = uuid
} }
await waitJobs(servers)
} }
await waitJobs(servers)
{ {
await servers[0].config.enableTranscoding({ hls: true, webVideo: false }) await servers[0].config.enableTranscoding({ hls: true, webVideo: false, resolutions })
const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' }) const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' })
hlsId = uuid hlsId = uuid
await waitJobs(servers)
} }
await waitJobs(servers)
{ {
await servers[0].config.enableTranscoding({ webVideo: true, hls: false }) await servers[0].config.enableTranscoding({ webVideo: true, hls: false, resolutions })
const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' }) const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' })
webVideoId = uuid webVideoId = uuid
await waitJobs(servers)
}
{
await servers[0].config.enableTranscoding({ webVideo: true, hls: true, splitAudioAndVideo: true, resolutions })
const { uuid } = await servers[0].videos.quickUpload({ name: 'splitted-audio-video' })
splittedHLSId = uuid
await waitJobs(servers)
}
{
await servers[0].config.enableTranscoding({
webVideo: true,
hls: true,
splitAudioAndVideo: false,
resolutions
})
const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' })
hlsWithAudioId = uuid
} }
await waitJobs(servers) await waitJobs(servers)
@ -168,9 +194,6 @@ describe('Test videos files', function () {
}) })
it('Should not delete files if the files are not available', async function () { it('Should not delete files if the files are not available', async function () {
await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
await servers[0].videos.removeAllWebVideoFiles({ videoId: webVideoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
}) })
@ -187,6 +210,40 @@ describe('Test videos files', function () {
await servers[0].videos.removeHLSPlaylist({ videoId: validId1 }) await servers[0].videos.removeHLSPlaylist({ videoId: validId1 })
await servers[0].videos.removeAllWebVideoFiles({ videoId: validId2 }) await servers[0].videos.removeAllWebVideoFiles({ videoId: validId2 })
}) })
it('Should not delete audio if audio and video is splitted', async function () {
const video = await servers[0].videos.get({ id: splittedHLSId })
const audio = getHLS(video).files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO)
await servers[0].videos.removeHLSFile({ videoId: splittedHLSId, fileId: audio.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should be able to delete audio if audio is the latest resolution', async function () {
const video = await servers[0].videos.get({ id: splittedHLSId })
const audio = getHLS(video).files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO)
for (const file of getHLS(video).files) {
if (file.resolution.id === VideoResolution.H_NOVIDEO) continue
await servers[0].videos.removeHLSFile({ videoId: splittedHLSId, fileId: file.id })
}
await servers[0].videos.removeHLSFile({ videoId: splittedHLSId, fileId: audio.id })
})
it('Should be able to delete audio of web video', async function () {
const video = await servers[0].videos.get({ id: splittedHLSId })
const audio = video.files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO)
await servers[0].videos.removeWebVideoFile({ videoId: splittedHLSId, fileId: audio.id })
})
it('Should be able to delete audio if audio and video are not splitted', async function () {
const video = await servers[0].videos.get({ id: hlsWithAudioId })
const audio = getHLS(video).files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO)
await servers[0].videos.removeHLSFile({ videoId: hlsWithAudioId, fileId: audio.id })
})
}) })
after(async function () { after(async function () {

View File

@ -204,7 +204,7 @@ describe('Test video sources API validator', function () {
await makeRawRequest({ url: source.fileDownloadUrl, token: user3, expectedStatus: HttpStatusCode.OK_200 }) await makeRawRequest({ url: source.fileDownloadUrl, token: user3, expectedStatus: HttpStatusCode.OK_200 })
}) })
it('Should succeed with a valid header token', async function () { it('Should succeed with a valid query token', async function () {
await makeRawRequest({ url: source.fileDownloadUrl, query: { videoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) await makeRawRequest({ url: source.fileDownloadUrl, query: { videoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
}) })
}) })

View File

@ -1,8 +1,9 @@
import './live-constraints.js' import './live-constraints.js'
import './live-fast-restream.js' import './live-fast-restream.js'
import './live-socket-messages.js'
import './live-privacy-update.js'
import './live-permanent.js' import './live-permanent.js'
import './live-privacy-update.js'
import './live-rtmps.js' import './live-rtmps.js'
import './live-save-replay.js' import './live-save-replay.js'
import './live-socket-messages.js'
import './live-audio-or-video-only.js'
import './live.js' import './live.js'

View File

@ -0,0 +1,236 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { Video, VideoResolution } from '@peertube/peertube-models'
import {
PeerTubeServer,
cleanupTests, createMultipleServers,
doubleFollow,
findExternalSavedVideo,
setAccessTokensToServers,
setDefaultVideoChannel,
stopFfmpeg,
waitJobs,
waitUntilLivePublishedOnAllServers,
waitUntilLiveReplacedByReplayOnAllServers,
waitUntilLiveWaitingOnAllServers
} from '@peertube/peertube-server-commands'
import { SQLCommand } from '@tests/shared/sql-command.js'
import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js'
import { checkLiveCleanup, testLiveVideoResolutions } from '../../shared/live.js'
describe('Test live audio only (input or output)', function () {
let servers: PeerTubeServer[] = []
let sqlCommandServer1: SQLCommand
function updateConf (transcodingEnabled: boolean, resolutions?: number[]) {
return servers[0].config.enableLive({
allowReplay: true,
resolutions: resolutions ?? 'min',
alwaysTranscodeOriginalResolution: false,
transcoding: transcodingEnabled,
maxDuration: -1
})
}
async function runAndCheckAudioLive (options: {
permanentLive: boolean
saveReplay: boolean
transcoded: boolean
mode: 'video-only' | 'audio-only'
fixture?: string
resolutions?: number[]
}) {
const { transcoded, permanentLive, saveReplay, mode } = options
const { video: liveVideo } = await servers[0].live.quickCreate({ permanentLive, saveReplay })
let fixtureName = options.fixture
let resolutions = options.resolutions
if (mode === 'audio-only') {
if (!fixtureName) fixtureName = 'sample.ogg'
if (!resolutions) resolutions = [ VideoResolution.H_NOVIDEO ]
} else if (mode === 'video-only') {
if (!fixtureName) fixtureName = 'video_short_no_audio.mp4'
if (!resolutions) resolutions = [ VideoResolution.H_720P ]
}
const hasVideo = mode === 'video-only'
const hasAudio = mode === 'audio-only'
const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideo.uuid, fixtureName })
await waitUntilLivePublishedOnAllServers(servers, liveVideo.uuid)
await waitJobs(servers)
await testLiveVideoResolutions({
originServer: servers[0],
sqlCommand: sqlCommandServer1,
servers,
liveVideoId: liveVideo.uuid,
resolutions,
hasAudio,
hasVideo,
transcoded
})
await stopFfmpeg(ffmpegCommand)
return liveVideo
}
before(async function () {
this.timeout(120000)
servers = await createMultipleServers(2)
// Get the access tokens
await setAccessTokensToServers(servers)
await setDefaultVideoChannel(servers)
await servers[0].config.enableMinimumTranscoding()
await servers[0].config.enableLive({ allowReplay: true, transcoding: true })
// Server 1 and server 2 follow each other
await doubleFollow(servers[0], servers[1])
sqlCommandServer1 = new SQLCommand(servers[0])
})
describe('Audio input only', function () {
let liveVideo: Video
it('Should mux an audio input only', async function () {
this.timeout(120000)
await updateConf(false)
await runAndCheckAudioLive({ mode: 'audio-only', permanentLive: false, saveReplay: false, transcoded: false })
})
it('Should correctly handle an audio input only', async function () {
this.timeout(120000)
await updateConf(true)
liveVideo = await runAndCheckAudioLive({ mode: 'audio-only', permanentLive: true, saveReplay: true, transcoded: true })
})
it('Should save the replay of an audio input only in a permanent live', async function () {
this.timeout(120000)
await waitUntilLiveWaitingOnAllServers(servers, liveVideo.uuid)
await waitJobs(servers)
await checkLiveCleanup({ server: servers[0], videoUUID: liveVideo.uuid, permanent: true })
const video = await findExternalSavedVideo(servers[0], liveVideo.uuid)
await completeCheckHlsPlaylist({
hlsOnly: true,
servers,
videoUUID: video.uuid,
resolutions: [ 0 ],
hasVideo: false,
splittedAudio: false // audio is not splitted because we only have an audio stream
})
})
})
describe('Audio output only', function () {
let liveVideo: Video
before(async function () {
await updateConf(true, [ VideoResolution.H_NOVIDEO ])
})
it('Should correctly handle an audio output only with an audio input only', async function () {
this.timeout(120000)
await runAndCheckAudioLive({ mode: 'audio-only', permanentLive: false, saveReplay: false, transcoded: true })
})
it('Should correctly handle an audio output only with a video & audio input', async function () {
this.timeout(120000)
liveVideo = await runAndCheckAudioLive({
mode: 'audio-only',
fixture: 'video_short.mp4',
permanentLive: false,
saveReplay: true,
transcoded: true
})
})
it('Should save the replay of an audio output only in a normal live', async function () {
this.timeout(120000)
await waitUntilLiveReplacedByReplayOnAllServers(servers, liveVideo.uuid)
await waitJobs(servers)
await checkLiveCleanup({ server: servers[0], videoUUID: liveVideo.uuid, permanent: false, savedResolutions: [ 0 ] })
await completeCheckHlsPlaylist({
hlsOnly: true,
servers,
videoUUID: liveVideo.uuid,
resolutions: [ 0 ],
hasVideo: false,
splittedAudio: false // audio is not splitted because we only have an audio stream
})
})
it('Should handle a video input only even if there is only the audio output', async function () {
this.timeout(120000)
await runAndCheckAudioLive({
mode: 'video-only',
permanentLive: false,
saveReplay: false,
transcoded: true,
resolutions: [ VideoResolution.H_720P ]
})
})
})
describe('Video input only', function () {
let liveVideo: Video
it('Should correctly handle a video input only', async function () {
this.timeout(120000)
await updateConf(true, [ VideoResolution.H_NOVIDEO, VideoResolution.H_240P ])
liveVideo = await runAndCheckAudioLive({
mode: 'video-only',
permanentLive: true,
saveReplay: true,
transcoded: true,
resolutions: [ VideoResolution.H_240P ]
})
})
it('Should save the replay of a video output only in a permanent live', async function () {
this.timeout(120000)
await waitUntilLiveWaitingOnAllServers(servers, liveVideo.uuid)
await waitJobs(servers)
await checkLiveCleanup({ server: servers[0], videoUUID: liveVideo.uuid, permanent: true })
const video = await findExternalSavedVideo(servers[0], liveVideo.uuid)
await completeCheckHlsPlaylist({
hlsOnly: true,
servers,
videoUUID: video.uuid,
resolutions: [ VideoResolution.H_240P ],
hasAudio: false,
splittedAudio: false // audio is not splitted because we only have a video stream
})
})
})
after(async function () {
if (sqlCommandServer1) await sqlCommandServer1.cleanup()
await cleanupTests(servers)
})
})

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { wait } from '@peertube/peertube-core-utils' import { wait } from '@peertube/peertube-core-utils'
import { LiveVideoError, UserVideoQuota, VideoPrivacy } from '@peertube/peertube-models' import { LiveVideoError, UserVideoQuota, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
import { import {
PeerTubeServer, PeerTubeServer,
cleanupTests, createMultipleServers, cleanupTests, createMultipleServers,
@ -38,14 +38,14 @@ describe('Test live constraints', function () {
return uuid return uuid
} }
async function checkSaveReplay (videoId: string, resolutions = [ 720 ]) { async function checkSaveReplay (videoId: string, savedResolutions?: number[]) {
for (const server of servers) { for (const server of servers) {
const video = await server.videos.get({ id: videoId }) const video = await server.videos.get({ id: videoId })
expect(video.isLive).to.be.false expect(video.isLive).to.be.false
expect(video.duration).to.be.greaterThan(0) expect(video.duration).to.be.greaterThan(0)
} }
await checkLiveCleanup({ server: servers[0], permanent: false, videoUUID: videoId, savedResolutions: resolutions }) await checkLiveCleanup({ server: servers[0], permanent: false, videoUUID: videoId, savedResolutions })
} }
function updateQuota (options: { total: number, daily: number }) { function updateQuota (options: { total: number, daily: number }) {
@ -100,7 +100,7 @@ describe('Test live constraints', function () {
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId) await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
await waitJobs(servers) await waitJobs(servers)
await checkSaveReplay(userVideoLiveoId) await checkSaveReplay(userVideoLiveoId, [ VideoResolution.H_720P ])
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED) expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
@ -136,7 +136,7 @@ describe('Test live constraints', function () {
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId) await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
await waitJobs(servers) await waitJobs(servers)
await checkSaveReplay(userVideoLiveoId) await checkSaveReplay(userVideoLiveoId, [ VideoResolution.H_720P ])
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED) expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
@ -223,7 +223,7 @@ describe('Test live constraints', function () {
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId) await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
await waitJobs(servers) await waitJobs(servers)
await checkSaveReplay(userVideoLiveoId, [ 720, 240, 144 ]) await checkSaveReplay(userVideoLiveoId, [ 720, 240, 144, 0 ])
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
expect(session.error).to.equal(LiveVideoError.DURATION_EXCEEDED) expect(session.error).to.equal(LiveVideoError.DURATION_EXCEEDED)

View File

@ -61,7 +61,7 @@ describe('Permanent live', function () {
maxDuration: -1, maxDuration: -1,
transcoding: { transcoding: {
enabled: true, enabled: true,
resolutions: ConfigCommand.getCustomConfigResolutions(true) resolutions: ConfigCommand.getConfigResolutions(true)
} }
} }
} }
@ -152,7 +152,7 @@ describe('Permanent live', function () {
maxDuration: -1, maxDuration: -1,
transcoding: { transcoding: {
enabled: true, enabled: true,
resolutions: ConfigCommand.getCustomConfigResolutions(false) resolutions: ConfigCommand.getConfigResolutions(false)
} }
} }
} }
@ -167,8 +167,8 @@ describe('Permanent live', function () {
await checkVideoState(videoUUID, VideoState.PUBLISHED) await checkVideoState(videoUUID, VideoState.PUBLISHED)
const count = await servers[0].live.countPlaylists({ videoUUID }) const count = await servers[0].live.countPlaylists({ videoUUID })
// master playlist and 720p playlist // master playlist, 720p playlist and audio only playlist
expect(count).to.equal(2) expect(count).to.equal(3)
await stopFfmpeg(ffmpegCommand) await stopFfmpeg(ffmpegCommand)
}) })

View File

@ -155,7 +155,7 @@ describe('Save replay setting', function () {
maxDuration: -1, maxDuration: -1,
transcoding: { transcoding: {
enabled: false, enabled: false,
resolutions: ConfigCommand.getCustomConfigResolutions(true) resolutions: ConfigCommand.getConfigResolutions(true)
} }
} }
} }
@ -422,14 +422,12 @@ describe('Save replay setting', function () {
it('Should correctly have saved the live', async function () { it('Should correctly have saved the live', async function () {
this.timeout(120000) this.timeout(120000)
const liveDetails = await servers[0].videos.get({ id: liveVideoUUID })
await stopFfmpeg(ffmpegCommand) await stopFfmpeg(ffmpegCommand)
await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID) await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID)
await waitJobs(servers) await waitJobs(servers)
const video = await findExternalSavedVideo(servers[0], liveDetails) const video = await findExternalSavedVideo(servers[0], liveVideoUUID)
expect(video).to.exist expect(video).to.exist
await servers[0].videos.get({ id: video.uuid }) await servers[0].videos.get({ id: video.uuid })
@ -508,14 +506,12 @@ describe('Save replay setting', function () {
it('Should correctly have saved the live and federated it after the streaming', async function () { it('Should correctly have saved the live and federated it after the streaming', async function () {
this.timeout(120000) this.timeout(120000)
const liveDetails = await servers[0].videos.get({ id: liveVideoUUID })
await stopFfmpeg(ffmpegCommand) await stopFfmpeg(ffmpegCommand)
await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID) await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID)
await waitJobs(servers) await waitJobs(servers)
const video = await findExternalSavedVideo(servers[0], liveDetails) const video = await findExternalSavedVideo(servers[0], liveVideoUUID)
expect(video).to.exist expect(video).to.exist
for (const server of servers) { for (const server of servers) {
@ -569,7 +565,7 @@ describe('Save replay setting', function () {
replaySettings: { privacy: VideoPrivacy.PUBLIC } replaySettings: { privacy: VideoPrivacy.PUBLIC }
}) })
const replay = await findExternalSavedVideo(servers[0], liveDetails) const replay = await findExternalSavedVideo(servers[0], liveDetails.uuid)
expect(replay).to.exist expect(replay).to.exist
for (const videoId of [ liveVideoUUID, replay.uuid ]) { for (const videoId of [ liveVideoUUID, replay.uuid ]) {
@ -591,7 +587,7 @@ describe('Save replay setting', function () {
replaySettings: { privacy: VideoPrivacy.PUBLIC } replaySettings: { privacy: VideoPrivacy.PUBLIC }
}) })
const replay = await findExternalSavedVideo(servers[0], liveDetails) const replay = await findExternalSavedVideo(servers[0], liveDetails.uuid)
expect(replay).to.not.exist expect(replay).to.not.exist
await checkVideosExist(liveVideoUUID, 1, HttpStatusCode.NOT_FOUND_404) await checkVideosExist(liveVideoUUID, 1, HttpStatusCode.NOT_FOUND_404)

View File

@ -1,9 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { basename, join } from 'path'
import { getAllFiles, wait } from '@peertube/peertube-core-utils' import { getAllFiles, wait } from '@peertube/peertube-core-utils'
import { ffprobePromise, getVideoStream } from '@peertube/peertube-ffmpeg' import { ffprobePromise } from '@peertube/peertube-ffmpeg'
import { import {
HttpStatusCode, HttpStatusCode,
LiveVideo, LiveVideo,
@ -12,6 +10,7 @@ import {
VideoCommentPolicy, VideoCommentPolicy,
VideoDetails, VideoDetails,
VideoPrivacy, VideoPrivacy,
VideoResolution,
VideoState, VideoState,
VideoStreamingPlaylistType VideoStreamingPlaylistType
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
@ -35,6 +34,8 @@ import {
import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js'
import { testLiveVideoResolutions } from '@tests/shared/live.js' import { testLiveVideoResolutions } from '@tests/shared/live.js'
import { SQLCommand } from '@tests/shared/sql-command.js' import { SQLCommand } from '@tests/shared/sql-command.js'
import { expect } from 'chai'
import { basename, join } from 'path'
describe('Test live', function () { describe('Test live', function () {
let servers: PeerTubeServer[] = [] let servers: PeerTubeServer[] = []
@ -399,38 +400,22 @@ describe('Test live', function () {
} }
function updateConf (resolutions: number[]) { function updateConf (resolutions: number[]) {
return servers[0].config.updateExistingConfig({ return servers[0].config.enableLive({
newConfig: { allowReplay: true,
live: { resolutions,
enabled: true, transcoding: true,
allowReplay: true, maxDuration: -1
maxDuration: -1,
transcoding: {
enabled: true,
resolutions: {
'144p': resolutions.includes(144),
'240p': resolutions.includes(240),
'360p': resolutions.includes(360),
'480p': resolutions.includes(480),
'720p': resolutions.includes(720),
'1080p': resolutions.includes(1080),
'2160p': resolutions.includes(2160)
}
}
}
}
}) })
} }
before(async function () { before(async function () {
await updateConf([])
sqlCommandServer1 = new SQLCommand(servers[0]) sqlCommandServer1 = new SQLCommand(servers[0])
}) })
it('Should enable transcoding without additional resolutions', async function () { it('Should enable transcoding without additional resolutions', async function () {
this.timeout(120000) this.timeout(120000)
await updateConf([])
liveVideoId = await createLiveWrapper(false) liveVideoId = await createLiveWrapper(false)
const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId }) const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId })
@ -449,18 +434,6 @@ describe('Test live', function () {
await stopFfmpeg(ffmpegCommand) await stopFfmpeg(ffmpegCommand)
}) })
it('Should transcode audio only RTMP stream', async function () {
this.timeout(120000)
liveVideoId = await createLiveWrapper(false)
const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_short_no_audio.mp4' })
await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
await waitJobs(servers)
await stopFfmpeg(ffmpegCommand)
})
it('Should enable transcoding with some resolutions', async function () { it('Should enable transcoding with some resolutions', async function () {
this.timeout(240000) this.timeout(240000)
@ -541,15 +514,17 @@ describe('Test live', function () {
await waitUntilLivePublishedOnAllServers(servers, liveVideoId) await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
const maxBitrateLimits = { const maxBitrateLimits = {
720: 6500 * 1000, // 60FPS 720: 6350 * 1000, // 60FPS
360: 1250 * 1000, 360: 1100 * 1000,
240: 700 * 1000 240: 600 * 1000,
0: 170 * 1000
} }
const minBitrateLimits = { const minBitrateLimits = {
720: 4800 * 1000, 720: 4650 * 1000,
360: 1000 * 1000, 360: 850 * 1000,
240: 550 * 1000 240: 400 * 1000,
0: 100 * 1000
} }
for (const server of servers) { for (const server of servers) {
@ -568,9 +543,10 @@ describe('Test live', function () {
expect(basename(hlsPlaylist.playlistUrl)).to.not.equal('master.m3u8') expect(basename(hlsPlaylist.playlistUrl)).to.not.equal('master.m3u8')
expect(basename(hlsPlaylist.segmentsSha256Url)).to.not.equal('segments-sha256.json') expect(basename(hlsPlaylist.segmentsSha256Url)).to.not.equal('segments-sha256.json')
expect(hlsPlaylist.files).to.have.lengthOf(resolutions.length) const resolutionsAndAudio = [ VideoResolution.H_NOVIDEO, ...resolutions ]
expect(hlsPlaylist.files).to.have.lengthOf(resolutionsAndAudio.length)
for (const resolution of resolutions) { for (const resolution of resolutionsAndAudio) {
const file = hlsPlaylist.files.find(f => f.resolution.id === resolution) const file = hlsPlaylist.files.find(f => f.resolution.id === resolution)
expect(file).to.exist expect(file).to.exist
@ -578,6 +554,8 @@ describe('Test live', function () {
if (resolution >= 720) { if (resolution >= 720) {
expect(file.fps).to.be.approximately(60, 10) expect(file.fps).to.be.approximately(60, 10)
} else if (resolution === VideoResolution.H_NOVIDEO) {
expect(file.fps).to.equal(0)
} else { } else {
expect(file.fps).to.be.approximately(30, 3) expect(file.fps).to.be.approximately(30, 3)
} }
@ -588,10 +566,9 @@ describe('Test live', function () {
const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename)) const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename))
const probe = await ffprobePromise(segmentPath) const probe = await ffprobePromise(segmentPath)
const videoStream = await getVideoStream(segmentPath, probe)
expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height]) expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[resolution])
expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height]) expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[resolution])
await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 }) await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 })
await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
@ -640,11 +617,12 @@ describe('Test live', function () {
const video = await servers[0].videos.get({ id: liveVideoId }) const video = await servers[0].videos.get({ id: liveVideoId })
const hlsFiles = video.streamingPlaylists[0].files const hlsFiles = video.streamingPlaylists[0].files
expect(video.files).to.have.lengthOf(0) const resolutionsWithAudio = [ VideoResolution.H_NOVIDEO, ...resolutions ]
expect(hlsFiles).to.have.lengthOf(resolutions.length)
// eslint-disable-next-line @typescript-eslint/require-array-sort-compare expect(video.files).to.have.lengthOf(0)
expect(getAllFiles(video).map(f => f.resolution.id).sort()).to.deep.equal(resolutions) expect(hlsFiles).to.have.lengthOf(resolutionsWithAudio.length)
expect(getAllFiles(video).map(f => f.resolution.id)).to.have.members(resolutionsWithAudio)
}) })
it('Should only keep the original resolution if all resolutions are disabled', async function () { it('Should only keep the original resolution if all resolutions are disabled', async function () {
@ -677,9 +655,9 @@ describe('Test live', function () {
const hlsFiles = video.streamingPlaylists[0].files const hlsFiles = video.streamingPlaylists[0].files
expect(video.files).to.have.lengthOf(0) expect(video.files).to.have.lengthOf(0)
expect(hlsFiles).to.have.lengthOf(1)
expect(hlsFiles[0].resolution.id).to.equal(720) expect(hlsFiles).to.have.lengthOf(2)
expect(hlsFiles.map(f => f.resolution.id)).to.have.members([ VideoResolution.H_720P, VideoResolution.H_NOVIDEO ])
}) })
after(async function () { after(async function () {

View File

@ -451,14 +451,12 @@ describe('Test user notifications', function () {
await waitJobs(servers) await waitJobs(servers)
await servers[1].live.waitUntilPublished({ videoId: shortUUID }) await servers[1].live.waitUntilPublished({ videoId: shortUUID })
const liveDetails = await servers[1].videos.get({ id: shortUUID })
await stopFfmpeg(ffmpegCommand) await stopFfmpeg(ffmpegCommand)
await servers[1].live.waitUntilWaiting({ videoId: shortUUID }) await servers[1].live.waitUntilWaiting({ videoId: shortUUID })
await waitJobs(servers) await waitJobs(servers)
const video = await findExternalSavedVideo(servers[1], liveDetails) const video = await findExternalSavedVideo(servers[1], shortUUID)
expect(video).to.exist expect(video).to.exist
await checkMyVideoIsPublished({ ...baseParams, videoName: video.name, shortUUID: video.shortUUID, checkType: 'presence' }) await checkMyVideoIsPublished({ ...baseParams, videoName: video.name, shortUUID: video.shortUUID, checkType: 'presence' })

View File

@ -1,8 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai' import { HttpStatusCode, LiveVideoCreate, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import { HttpStatusCode, LiveVideoCreate, VideoPrivacy } from '@peertube/peertube-models'
import { import {
cleanupTests, cleanupTests,
createMultipleServers, createMultipleServers,
@ -23,6 +22,7 @@ import { expectStartWith } from '@tests/shared/checks.js'
import { testLiveVideoResolutions } from '@tests/shared/live.js' import { testLiveVideoResolutions } from '@tests/shared/live.js'
import { MockObjectStorageProxy } from '@tests/shared/mock-servers/mock-object-storage.js' import { MockObjectStorageProxy } from '@tests/shared/mock-servers/mock-object-storage.js'
import { SQLCommand } from '@tests/shared/sql-command.js' import { SQLCommand } from '@tests/shared/sql-command.js'
import { expect } from 'chai'
async function createLive (server: PeerTubeServer, permanent: boolean) { async function createLive (server: PeerTubeServer, permanent: boolean) {
const attributes: LiveVideoCreate = { const attributes: LiveVideoCreate = {
@ -118,7 +118,7 @@ describe('Object storage for lives', function () {
let videoUUID: string let videoUUID: string
before(async function () { before(async function () {
await servers[0].config.enableLive({ transcoding: false }) await servers[0].config.enableLive({ transcoding: false, allowReplay: true })
videoUUID = await createLive(servers[0], false) videoUUID = await createLive(servers[0], false)
}) })
@ -157,10 +157,10 @@ describe('Object storage for lives', function () {
}) })
describe('With live transcoding', function () { describe('With live transcoding', function () {
const resolutions = [ 720, 480, 360, 240, 144 ] const resolutions = [ VideoResolution.H_720P, VideoResolution.H_240P ]
before(async function () { before(async function () {
await servers[0].config.enableLive({ transcoding: true }) await servers[0].config.enableLive({ transcoding: true, resolutions })
}) })
describe('Normal replay', function () { describe('Normal replay', function () {
@ -195,7 +195,8 @@ describe('Object storage for lives', function () {
await waitUntilLiveReplacedByReplayOnAllServers(servers, videoUUIDNonPermanent) await waitUntilLiveReplacedByReplayOnAllServers(servers, videoUUIDNonPermanent)
await waitJobs(servers) await waitJobs(servers)
await checkFilesExist({ servers, videoUUID: videoUUIDNonPermanent, numberOfFiles: 5, objectStorage }) const numberOfFiles = resolutions.length + 1 // +1 for the HLS audio file
await checkFilesExist({ servers, videoUUID: videoUUIDNonPermanent, numberOfFiles, objectStorage })
}) })
it('Should have cleaned up live files from object storage', async function () { it('Should have cleaned up live files from object storage', async function () {
@ -235,10 +236,10 @@ describe('Object storage for lives', function () {
await waitUntilLiveWaitingOnAllServers(servers, videoUUIDPermanent) await waitUntilLiveWaitingOnAllServers(servers, videoUUIDPermanent)
await waitJobs(servers) await waitJobs(servers)
const videoLiveDetails = await servers[0].videos.get({ id: videoUUIDPermanent }) const replay = await findExternalSavedVideo(servers[0], videoUUIDPermanent)
const replay = await findExternalSavedVideo(servers[0], videoLiveDetails)
await checkFilesExist({ servers, videoUUID: replay.uuid, numberOfFiles: 5, objectStorage }) const numberOfFiles = resolutions.length + 1 // +1 for the HLS audio file
await checkFilesExist({ servers, videoUUID: replay.uuid, numberOfFiles, objectStorage })
}) })
it('Should have cleaned up live files from object storage', async function () { it('Should have cleaned up live files from object storage', async function () {

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { getAllFiles, getHLS } from '@peertube/peertube-core-utils' import { getAllFiles, getHLS } from '@peertube/peertube-core-utils'
import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
import { areScalewayObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' import { areScalewayObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import { import {
cleanupTests, cleanupTests,
@ -300,7 +300,7 @@ describe('Object storage for video static file privacy', function () {
server, server,
videoUUID: privateVideoUUID, videoUUID: privateVideoUUID,
videoFileToken, videoFileToken,
resolutions: [ 240, 720 ], resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ],
isLive: false isLive: false
}) })
}) })
@ -491,7 +491,7 @@ describe('Object storage for video static file privacy', function () {
server, server,
videoUUID: permanentLiveId, videoUUID: permanentLiveId,
videoFileToken, videoFileToken,
resolutions: [ 720 ], resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ],
isLive: true isLive: true
}) })
@ -513,8 +513,7 @@ describe('Object storage for video static file privacy', function () {
await server.live.waitUntilWaiting({ videoId: permanentLiveId }) await server.live.waitUntilWaiting({ videoId: permanentLiveId })
await waitJobs([ server ]) await waitJobs([ server ])
const live = await server.videos.getWithToken({ id: permanentLiveId }) const replayFromList = await findExternalSavedVideo(server, permanentLiveId)
const replayFromList = await findExternalSavedVideo(server, live)
const replay = await server.videos.getWithToken({ id: replayFromList.id }) const replay = await server.videos.getWithToken({ id: replayFromList.id })
await checkReplay(replay) await checkReplay(replay)

View File

@ -561,7 +561,7 @@ describe('Test runner common actions', function () {
const { data } = await server.runnerJobs.list({ count: 50, sort: '-updatedAt' }) const { data } = await server.runnerJobs.list({ count: 50, sort: '-updatedAt' })
const children = data.filter(j => j.parent?.uuid === failedJob.uuid) const children = data.filter(j => j.parent?.uuid === failedJob.uuid)
expect(children).to.have.lengthOf(9) expect(children).to.have.lengthOf(5)
for (const child of children) { for (const child of children) {
expect(child.parent.uuid).to.equal(failedJob.uuid) expect(child.parent.uuid).to.equal(failedJob.uuid)
@ -599,7 +599,7 @@ describe('Test runner common actions', function () {
{ {
const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' })
const children = data.filter(j => j.parent?.uuid === jobUUID) const children = data.filter(j => j.parent?.uuid === jobUUID)
expect(children).to.have.lengthOf(9) expect(children).to.have.lengthOf(5)
for (const child of children) { for (const child of children) {
expect(child.state.id).to.equal(RunnerJobState.PARENT_CANCELLED) expect(child.state.id).to.equal(RunnerJobState.PARENT_CANCELLED)

View File

@ -119,13 +119,17 @@ describe('Test runner live transcoding', function () {
expect(job.type).to.equal('live-rtmp-hls-transcoding') expect(job.type).to.equal('live-rtmp-hls-transcoding')
expect(job.payload.input.rtmpUrl).to.exist expect(job.payload.input.rtmpUrl).to.exist
expect(job.payload.output.toTranscode).to.have.lengthOf(5) expect(job.payload.output.toTranscode).to.have.lengthOf(6)
for (const { resolution, fps } of job.payload.output.toTranscode) { for (const { resolution, fps } of job.payload.output.toTranscode) {
expect([ 720, 480, 360, 240, 144 ]).to.contain(resolution) expect([ 720, 480, 360, 240, 144, 0 ]).to.contain(resolution)
expect(fps).to.be.above(25) if (resolution === 0) {
expect(fps).to.be.below(70) expect(fps).to.equal(0)
} else {
expect(fps).to.be.above(25)
expect(fps).to.be.below(70)
}
} }
}) })

View File

@ -23,7 +23,7 @@ describe('Test runner socket', function () {
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
await setDefaultVideoChannel([ server ]) await setDefaultVideoChannel([ server ])
await server.config.enableTranscoding({ hls: true, webVideo: true }) await server.config.enableTranscoding({ hls: false, webVideo: true })
await server.config.enableRemoteTranscoding() await server.config.enableRemoteTranscoding()
runnerToken = await server.runners.autoRegisterRunner() runnerToken = await server.runners.autoRegisterRunner()
}) })

View File

@ -111,6 +111,8 @@ describe('Test runner VOD transcoding', function () {
it('Should cancel a transcoding job', async function () { it('Should cancel a transcoding job', async function () {
await servers[0].runnerJobs.cancelAllJobs() await servers[0].runnerJobs.cancelAllJobs()
await servers[0].config.enableTranscoding({ hls: true, webVideo: false })
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
await waitJobs(servers) await waitJobs(servers)
@ -397,16 +399,18 @@ describe('Test runner VOD transcoding', function () {
await servers[0].runnerJobs.autoProcessWebVideoJob(runnerToken) await servers[0].runnerJobs.autoProcessWebVideoJob(runnerToken)
}) })
it('Should have 9 jobs to process', async function () { it('Should have 5 jobs to process', async function () {
const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken })
expect(availableJobs).to.have.lengthOf(9) expect(availableJobs).to.have.lengthOf(5)
const webVideoJobs = availableJobs.filter(j => j.type === 'vod-web-video-transcoding') const webVideoJobs = availableJobs.filter(j => j.type === 'vod-web-video-transcoding')
// Other HLS resolution jobs needs to web video transcoding to be processed first
const hlsJobs = availableJobs.filter(j => j.type === 'vod-hls-transcoding') const hlsJobs = availableJobs.filter(j => j.type === 'vod-hls-transcoding')
expect(webVideoJobs).to.have.lengthOf(4) expect(webVideoJobs).to.have.lengthOf(4)
expect(hlsJobs).to.have.lengthOf(5) expect(hlsJobs).to.have.lengthOf(1)
}) })
it('Should process all available jobs', async function () { it('Should process all available jobs', async function () {
@ -489,13 +493,13 @@ describe('Test runner VOD transcoding', function () {
} }
}) })
it('Should have 7 lower resolutions to transcode', async function () { it('Should have 4 lower resolutions to transcode', async function () {
const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken })
expect(availableJobs).to.have.lengthOf(7) expect(availableJobs).to.have.lengthOf(4)
for (const resolution of [ 360, 240, 144 ]) { for (const resolution of [ 360, 240, 144 ]) {
const jobs = availableJobs.filter(j => j.payload.output.resolution === resolution) const jobs = availableJobs.filter(j => j.payload.output.resolution === resolution)
expect(jobs).to.have.lengthOf(2) expect(jobs).to.have.lengthOf(1)
} }
jobUUID = availableJobs.find(j => j.payload.output.resolution === 480).uuid jobUUID = availableJobs.find(j => j.payload.output.resolution === 480).uuid

View File

@ -83,6 +83,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true
expect(data.transcoding.webVideos.enabled).to.be.true expect(data.transcoding.webVideos.enabled).to.be.true
expect(data.transcoding.hls.enabled).to.be.true expect(data.transcoding.hls.enabled).to.be.true
expect(data.transcoding.hls.splitAudioAndVideo).to.be.false
expect(data.transcoding.originalFile.keep).to.be.false expect(data.transcoding.originalFile.keep).to.be.false
expect(data.live.enabled).to.be.false expect(data.live.enabled).to.be.false
@ -95,6 +96,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.live.transcoding.remoteRunners.enabled).to.be.false expect(data.live.transcoding.remoteRunners.enabled).to.be.false
expect(data.live.transcoding.threads).to.equal(2) expect(data.live.transcoding.threads).to.equal(2)
expect(data.live.transcoding.profile).to.equal('default') expect(data.live.transcoding.profile).to.equal('default')
expect(data.live.transcoding.resolutions['0p']).to.be.false
expect(data.live.transcoding.resolutions['144p']).to.be.false expect(data.live.transcoding.resolutions['144p']).to.be.false
expect(data.live.transcoding.resolutions['240p']).to.be.false expect(data.live.transcoding.resolutions['240p']).to.be.false
expect(data.live.transcoding.resolutions['360p']).to.be.false expect(data.live.transcoding.resolutions['360p']).to.be.false
@ -257,7 +259,8 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
enabled: true enabled: true
}, },
hls: { hls: {
enabled: false enabled: false,
splitAudioAndVideo: true
} }
}, },
live: { live: {
@ -277,6 +280,7 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
threads: 4, threads: 4,
profile: 'live_profile', profile: 'live_profile',
resolutions: { resolutions: {
'0p': true,
'144p': true, '144p': true,
'240p': true, '240p': true,
'360p': true, '360p': true,

View File

@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { dateIsValid } from '@tests/shared/checks.js'
import { wait } from '@peertube/peertube-core-utils' import { wait } from '@peertube/peertube-core-utils'
import { import {
cleanupTests, cleanupTests,
@ -11,6 +9,8 @@ import {
setAccessTokensToServers, setAccessTokensToServers,
waitJobs waitJobs
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { dateIsValid } from '@tests/shared/checks.js'
import { expect } from 'chai'
describe('Test jobs', function () { describe('Test jobs', function () {
let servers: PeerTubeServer[] let servers: PeerTubeServer[]
@ -101,12 +101,13 @@ describe('Test jobs', function () {
{ {
const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' }) const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' })
// waiting includes waiting-children // root transcoding
expect(body.data).to.have.lengthOf(4) expect(body.data).to.have.lengthOf(1)
} }
{ {
const body = await servers[1].jobs.list({ state: 'waiting-children', jobType: 'video-transcoding' }) const body = await servers[1].jobs.list({ state: 'waiting-children', jobType: 'transcoding-job-builder' })
// next transcoding jobs
expect(body.data).to.have.lengthOf(1) expect(body.data).to.have.lengthOf(1)
} }
}) })

View File

@ -51,52 +51,61 @@ describe('Test audio only video transcoding', function () {
await doubleFollow(servers[0], servers[1]) await doubleFollow(servers[0], servers[1])
}) })
it('Should upload a video and transcode it', async function () { for (const concurrency of [ 1, 2 ]) {
this.timeout(120000) describe(`With transcoding concurrency ${concurrency}`, function () {
const { uuid } = await servers[0].videos.upload({ attributes: { name: 'audio only' } }) before(async function () {
videoUUID = uuid await servers[0].config.setTranscodingConcurrency(concurrency)
})
await waitJobs(servers) it('Should upload a video and transcode it', async function () {
this.timeout(120000)
for (const server of servers) { const { uuid } = await servers[0].videos.upload({ attributes: { name: 'audio only' } })
const video = await server.videos.get({ id: videoUUID }) videoUUID = uuid
expect(video.streamingPlaylists).to.have.lengthOf(1)
for (const files of [ video.files, video.streamingPlaylists[0].files ]) { await waitJobs(servers)
expect(files).to.have.lengthOf(3)
expect(files[0].resolution.id).to.equal(720)
expect(files[1].resolution.id).to.equal(240)
expect(files[2].resolution.id).to.equal(0)
}
if (server.serverNumber === 1) { for (const server of servers) {
webVideoAudioFileUrl = video.files[2].fileUrl const video = await server.videos.get({ id: videoUUID })
fragmentedAudioFileUrl = video.streamingPlaylists[0].files[2].fileUrl expect(video.streamingPlaylists).to.have.lengthOf(1)
}
}
})
it('0p transcoded video should not have video', async function () { for (const files of [ video.files, video.streamingPlaylists[0].files ]) {
const paths = [ expect(files).to.have.lengthOf(3)
servers[0].servers.buildWebVideoFilePath(webVideoAudioFileUrl), expect(files[0].resolution.id).to.equal(720)
servers[0].servers.buildFragmentedFilePath(videoUUID, fragmentedAudioFileUrl) expect(files[1].resolution.id).to.equal(240)
] expect(files[2].resolution.id).to.equal(0)
}
for (const path of paths) { if (server.serverNumber === 1) {
const { audioStream } = await getAudioStream(path) webVideoAudioFileUrl = video.files[2].fileUrl
expect(audioStream['codec_name']).to.be.equal('aac') fragmentedAudioFileUrl = video.streamingPlaylists[0].files[2].fileUrl
expect(audioStream['bit_rate']).to.be.at.most(384 * 8000) }
}
})
const size = await getVideoStreamDimensionsInfo(path) it('0p transcoded video should not have video', async function () {
const paths = [
servers[0].servers.buildWebVideoFilePath(webVideoAudioFileUrl),
servers[0].servers.buildFragmentedFilePath(videoUUID, fragmentedAudioFileUrl)
]
expect(size.height).to.equal(0) for (const path of paths) {
expect(size.width).to.equal(0) const { audioStream } = await getAudioStream(path)
expect(size.isPortraitMode).to.be.false expect(audioStream['codec_name']).to.be.equal('aac')
expect(size.ratio).to.equal(0) expect(audioStream['bit_rate']).to.be.at.most(384 * 8000)
expect(size.resolution).to.equal(0)
} const size = await getVideoStreamDimensionsInfo(path)
})
expect(size.height).to.equal(0)
expect(size.width).to.equal(0)
expect(size.isPortraitMode).to.be.false
expect(size.ratio).to.equal(0)
expect(size.resolution).to.equal(0)
}
})
})
}
after(async function () { after(async function () {
await cleanupTests(servers) await cleanupTests(servers)

View File

@ -39,7 +39,12 @@ async function checkFilesInObjectStorage (objectStorage: ObjectStorageCommand, v
await makeRawRequest({ url: hlsPlaylist.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) await makeRawRequest({ url: hlsPlaylist.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
} }
function runTests (enableObjectStorage: boolean) { function runTests (options: {
concurrency: number
enableObjectStorage: boolean
}) {
const { concurrency, enableObjectStorage } = options
let servers: PeerTubeServer[] = [] let servers: PeerTubeServer[] = []
let videoUUID: string let videoUUID: string
let publishedAt: string let publishedAt: string
@ -73,6 +78,7 @@ function runTests (enableObjectStorage: boolean) {
publishedAt = video.publishedAt as string publishedAt = video.publishedAt as string
await servers[0].config.enableTranscoding() await servers[0].config.enableTranscoding()
await servers[0].config.setTranscodingConcurrency(concurrency)
}) })
it('Should generate HLS', async function () { it('Should generate HLS', async function () {
@ -164,7 +170,7 @@ function runTests (enableObjectStorage: boolean) {
newConfig: { newConfig: {
transcoding: { transcoding: {
enabled: true, enabled: true,
resolutions: ConfigCommand.getCustomConfigResolutions(false), resolutions: ConfigCommand.getConfigResolutions(false),
webVideos: { webVideos: {
enabled: true enabled: true
@ -200,7 +206,7 @@ function runTests (enableObjectStorage: boolean) {
newConfig: { newConfig: {
transcoding: { transcoding: {
enabled: true, enabled: true,
resolutions: ConfigCommand.getCustomConfigResolutions(true), resolutions: ConfigCommand.getConfigResolutions(true),
webVideos: { webVideos: {
enabled: true enabled: true
@ -255,13 +261,18 @@ function runTests (enableObjectStorage: boolean) {
describe('Test create transcoding jobs from API', function () { describe('Test create transcoding jobs from API', function () {
describe('On filesystem', function () { for (const concurrency of [ 1, 2 ]) {
runTests(false) describe('With concurrency ' + concurrency, function () {
})
describe('On object storage', function () { describe('On filesystem', function () {
if (areMockObjectStorageTestsDisabled()) return runTests({ concurrency, enableObjectStorage: false })
})
runTests(true) describe('On object storage', function () {
}) if (areMockObjectStorageTestsDisabled()) return
runTests({ concurrency, enableObjectStorage: true })
})
})
}
}) })

View File

@ -19,9 +19,25 @@ import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js'
describe('Test HLS videos', function () { describe('Test HLS videos', function () {
let servers: PeerTubeServer[] = [] let servers: PeerTubeServer[] = []
function runTestSuite (hlsOnly: boolean, objectStorageBaseUrl?: string) { function runTestSuite (options: {
hlsOnly: boolean
concurrency: number
objectStorageBaseUrl?: string
}) {
const { hlsOnly, objectStorageBaseUrl, concurrency } = options
const videoUUIDs: string[] = [] const videoUUIDs: string[] = []
before(async function () {
await servers[0].config.enableTranscoding({
resolutions: [ 720, 480, 360, 240 ],
hls: true,
webVideo: !hlsOnly
})
await servers[0].config.setTranscodingConcurrency(concurrency)
})
it('Should upload a video and transcode it to HLS', async function () { it('Should upload a video and transcode it to HLS', async function () {
this.timeout(120000) this.timeout(120000)
@ -112,41 +128,18 @@ describe('Test HLS videos', function () {
await doubleFollow(servers[0], servers[1]) await doubleFollow(servers[0], servers[1])
}) })
describe('With Web Video & HLS enabled', function () { for (const concurrency of [ 1, 2 ]) {
runTestSuite(false) describe(`With concurrency ${concurrency}`, function () {
})
describe('With only HLS enabled', function () { describe('With Web Video & HLS enabled', function () {
runTestSuite({ hlsOnly: false, concurrency })
})
before(async function () { describe('With only HLS enabled', function () {
await servers[0].config.updateExistingConfig({ runTestSuite({ hlsOnly: true, concurrency })
newConfig: {
transcoding: {
enabled: true,
allowAudioFiles: true,
resolutions: {
'144p': false,
'240p': true,
'360p': true,
'480p': true,
'720p': true,
'1080p': true,
'1440p': true,
'2160p': true
},
hls: {
enabled: true
},
webVideos: {
enabled: false
}
}
}
}) })
}) })
}
runTestSuite(true)
})
describe('With object storage enabled', function () { describe('With object storage enabled', function () {
if (areMockObjectStorageTestsDisabled()) return if (areMockObjectStorageTestsDisabled()) return
@ -163,7 +156,11 @@ describe('Test HLS videos', function () {
await servers[0].run(configOverride) await servers[0].run(configOverride)
}) })
runTestSuite(true, objectStorage.getMockPlaylistBaseUrl()) for (const concurrency of [ 1, 2 ]) {
describe(`With concurrency ${concurrency}`, function () {
runTestSuite({ hlsOnly: true, concurrency, objectStorageBaseUrl: objectStorage.getMockPlaylistBaseUrl() })
})
}
after(async function () { after(async function () {
await objectStorage.cleanupMock() await objectStorage.cleanupMock()

View File

@ -1,6 +1,7 @@
export * from './audio-only.js' export * from './audio-only.js'
export * from './create-transcoding.js' export * from './create-transcoding.js'
export * from './hls.js' export * from './hls.js'
export * from './split-audio-and-video.js'
export * from './transcoder.js' export * from './transcoder.js'
export * from './update-while-transcoding.js' export * from './update-while-transcoding.js'
export * from './video-studio.js' export * from './video-studio.js'

View File

@ -0,0 +1,175 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { join } from 'path'
import { HttpStatusCode } from '@peertube/peertube-models'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import {
cleanupTests,
createMultipleServers,
doubleFollow,
ObjectStorageCommand,
PeerTubeServer,
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { DEFAULT_AUDIO_RESOLUTION } from '@peertube/peertube-server/core/initializers/constants.js'
import { checkDirectoryIsEmpty, checkTmpIsEmpty } from '@tests/shared/directories.js'
import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js'
describe('Test HLS with audio and video splitted', function () {
let servers: PeerTubeServer[] = []
function runTestSuite (options: {
hlsOnly: boolean
concurrency: number
objectStorageBaseUrl?: string
}) {
const { hlsOnly, objectStorageBaseUrl, concurrency } = options
const videoUUIDs: string[] = []
before(async function () {
await servers[0].config.enableTranscoding({
resolutions: [ 720, 480, 360, 240 ],
hls: true,
splitAudioAndVideo: true,
webVideo: !hlsOnly
})
await servers[0].config.setTranscodingConcurrency(concurrency)
})
it('Should upload a video and transcode it to HLS', async function () {
this.timeout(120000)
const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video 1', fixture: 'video_short.webm' } })
videoUUIDs.push(uuid)
await waitJobs(servers)
await completeCheckHlsPlaylist({ servers, videoUUID: uuid, hlsOnly, splittedAudio: true, objectStorageBaseUrl })
})
it('Should upload an audio file and transcode it to HLS', async function () {
this.timeout(120000)
const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video audio', fixture: 'sample.ogg' } })
videoUUIDs.push(uuid)
await waitJobs(servers)
await completeCheckHlsPlaylist({
servers,
videoUUID: uuid,
hlsOnly,
splittedAudio: true,
resolutions: [ DEFAULT_AUDIO_RESOLUTION, 360, 240 ],
objectStorageBaseUrl
})
})
it('Should update the video', async function () {
this.timeout(30000)
await servers[0].videos.update({ id: videoUUIDs[0], attributes: { name: 'video 1 updated' } })
await waitJobs(servers)
await completeCheckHlsPlaylist({ servers, videoUUID: videoUUIDs[0], hlsOnly, splittedAudio: true, objectStorageBaseUrl })
})
it('Should delete videos', async function () {
for (const uuid of videoUUIDs) {
await servers[0].videos.remove({ id: uuid })
}
await waitJobs(servers)
for (const server of servers) {
for (const uuid of videoUUIDs) {
await server.videos.get({ id: uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
}
}
})
it('Should have the playlists/segment deleted from the disk', async function () {
for (const server of servers) {
await checkDirectoryIsEmpty(server, 'web-videos', [ 'private' ])
await checkDirectoryIsEmpty(server, join('web-videos', 'private'))
await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls'), [ 'private' ])
await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls', 'private'))
}
})
it('Should have an empty tmp directory', async function () {
for (const server of servers) {
await checkTmpIsEmpty(server)
}
})
}
before(async function () {
this.timeout(120000)
const configOverride = {
transcoding: {
enabled: true,
allow_audio_files: true,
hls: {
enabled: true
}
}
}
servers = await createMultipleServers(2, configOverride)
// Get the access tokens
await setAccessTokensToServers(servers)
// Server 1 and server 2 follow each other
await doubleFollow(servers[0], servers[1])
})
for (const concurrency of [ 1, 2 ]) {
describe(`With concurrency ${concurrency}`, function () {
describe('With Web Video & HLS enabled', function () {
runTestSuite({ hlsOnly: false, concurrency })
})
describe('With only HLS enabled', function () {
runTestSuite({ hlsOnly: true, concurrency })
})
})
}
describe('With object storage enabled', function () {
if (areMockObjectStorageTestsDisabled()) return
const objectStorage = new ObjectStorageCommand()
before(async function () {
this.timeout(120000)
const configOverride = objectStorage.getDefaultMockConfig()
await objectStorage.prepareDefaultMockBuckets()
await servers[0].kill()
await servers[0].run(configOverride)
})
for (const concurrency of [ 1, 2 ]) {
describe(`With concurrency ${concurrency}`, function () {
runTestSuite({ hlsOnly: true, concurrency, objectStorageBaseUrl: objectStorage.getMockPlaylistBaseUrl() })
})
}
after(async function () {
await objectStorage.cleanupMock()
})
})
after(async function () {
await cleanupTests(servers)
})
})

Some files were not shown because too many files have changed in this diff Show More