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
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:
```

View File

@ -1,9 +1,10 @@
import { remove } from 'fs-extra/esm'
import { join } from 'path'
import { pick } from '@peertube/peertube-core-utils'
import { FFmpegEdition, FFmpegLive, FFmpegVOD, getDefaultAvailableEncoders, getDefaultEncodersToTry } from '@peertube/peertube-ffmpeg'
import { RunnerJob, RunnerJobPayload } from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
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 { getWinstonLogger } from './winston-logger.js'
@ -35,6 +36,18 @@ export async function downloadInputFile (options: {
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: {
server: PeerTubeServer
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 { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '@peertube/peertube-ffmpeg'
import {
ffprobePromise,
getVideoStreamBitrate,
getVideoStreamDimensionsInfo,
hasAudioStream,
hasVideoStream
} from '@peertube/peertube-ffmpeg'
import {
LiveRTMPHLSTranscodingSuccess,
LiveRTMPHLSTranscodingUpdatePayload,
@ -12,6 +14,10 @@ import {
ServerErrorCode
} from '@peertube/peertube-models'
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 { logger } from '../../../shared/index.js'
import { buildFFmpegLive, ProcessOptions } from './common.js'
@ -51,6 +57,7 @@ export class ProcessLiveRTMPHLSTranscoding {
logger.info({ probe }, `Probed ${payload.input.rtmpUrl}`)
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 { ratio } = await getVideoStreamDimensionsInfo(payload.input.rtmpUrl, probe)
@ -103,11 +110,13 @@ export class ProcessLiveRTMPHLSTranscoding {
segmentDuration: payload.output.segmentDuration,
toTranscode: payload.output.toTranscode,
splitAudioAndVideo: true,
bitrate,
ratio,
hasAudio,
hasVideo,
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 {
RunnerJobStudioTranscodingPayload,
@ -12,17 +10,30 @@ import {
VideoStudioTranscodingSuccess
} from '@peertube/peertube-models'
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 { 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>) {
const { server, job, runnerToken } = options
const payload = job.payload
let inputPath: string
let videoInputPath: string
let separatedAudioInputPath: string
let tmpVideoInputFilePath: string
let tmpSeparatedAudioInputFilePath: string
let outputPath: string
let tmpInputFilePath: string
let tasksProgress = 0
@ -36,8 +47,11 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
try {
logger.info(`Downloading input file ${payload.input.videoFileUrl} for job ${job.jobToken}`)
inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
tmpInputFilePath = inputPath
videoInputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
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.`)
@ -46,17 +60,20 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename)
await processTask({
inputPath: tmpInputFilePath,
videoInputPath: tmpVideoInputFilePath,
separatedAudioInputPath: tmpSeparatedAudioInputFilePath,
outputPath,
task,
job,
runnerToken
})
if (tmpInputFilePath) await remove(tmpInputFilePath)
if (tmpVideoInputFilePath) await remove(tmpVideoInputFilePath)
if (tmpSeparatedAudioInputFilePath) await remove(tmpSeparatedAudioInputFilePath)
// For the next iteration
tmpInputFilePath = outputPath
tmpVideoInputFilePath = outputPath
tmpSeparatedAudioInputFilePath = undefined
tasksProgress += Math.floor(100 / payload.tasks.length)
}
@ -72,7 +89,8 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
payload: successBody
})
} finally {
if (tmpInputFilePath) await remove(tmpInputFilePath)
if (tmpVideoInputFilePath) await remove(tmpVideoInputFilePath)
if (tmpSeparatedAudioInputFilePath) await remove(tmpSeparatedAudioInputFilePath)
if (outputPath) await remove(outputPath)
if (updateProgressInterval) clearInterval(updateProgressInterval)
}
@ -83,8 +101,11 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
// ---------------------------------------------------------------------------
type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
inputPath: string
videoInputPath: string
separatedAudioInputPath: string
outputPath: string
task: T
runnerToken: string
job: JobWithToken
@ -107,15 +128,15 @@ async function processTask (options: TaskProcessorOptions) {
}
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 })
try {
await buildFFmpegEdition().addIntroOutro({
...pick(options, [ 'inputPath', 'outputPath' ]),
...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
introOutroPath,
type: task.name === 'add-intro'
@ -128,12 +149,12 @@ async function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTa
}
function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
const { inputPath, task } = options
const { videoInputPath, task } = options
logger.debug(`Cutting ${inputPath}`)
logger.debug(`Cutting ${videoInputPath}`)
return buildFFmpegEdition().cutVideo({
...pick(options, [ 'inputPath', 'outputPath' ]),
...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
start: task.options.start,
end: task.options.end
@ -141,15 +162,15 @@ function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
}
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 })
try {
await buildFFmpegEdition().addWatermark({
...pick(options, [ 'inputPath', 'outputPath' ]),
...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
watermarkPath,

View File

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

View File

@ -11,12 +11,13 @@ export type ResolutionOption = {
@Injectable()
export class EditConfigurationService {
getVODResolutions () {
getTranscodingResolutions () {
return [
{
id: '0p',
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',
@ -53,14 +54,14 @@ export class EditConfigurationService {
]
}
getLiveResolutions () {
return this.getVODResolutions().filter(r => r.id !== '0p')
}
isTranscodingEnabled (form: FormGroup) {
return form.value['transcoding']['enabled'] === true
}
isHLSEnabled (form: FormGroup) {
return form.value['transcoding']['hls']['enabled'] === true
}
isRemoteRunnerVODEnabled (form: FormGroup) {
return form.value['transcoding']['remoteRunners']['enabled'] === true
}

View File

@ -152,3 +152,8 @@ my-actor-banner-edit {
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 { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { NgFor, NgIf } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service'
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 { FormReactive } from '@app/shared/shared-forms/form-reactive'
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 { 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 { 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 { 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 & {
instanceCustomHomepage: CustomPage
@ -230,7 +230,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
keep: null
},
hls: {
enabled: null
enabled: null,
splitAudioAndVideo: null
},
webVideos: {
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'
formGroupData.transcoding.resolutions[resolution.id] = null
}
for (const resolution of this.editConfigurationService.getLiveResolutions()) {
defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
formGroupData.live.transcoding.resolutions[resolution.id] = null
}

View File

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

View File

@ -56,7 +56,7 @@ export class EditLiveConfigurationComponent implements OnInit, OnChanges {
{ id: 1000 * 3600 * 10, label: $localize`10 hours` }
]
this.liveResolutions = this.editConfigurationService.getLiveResolutions()
this.liveResolutions = this.editConfigurationService.getTranscodingResolutions()
}
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>
</ng-container>
</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>
</div>
</ng-container>
@ -123,16 +141,6 @@
<div class="mb-2 fw-bold" i18n>Resolutions to generate</div>
<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">
<div class="form-group" *ngFor="let resolution of resolutions">
<my-peertube-checkbox
@ -145,6 +153,15 @@
</my-peertube-checkbox>
</div>
</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>

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 { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { Notifier } from '@app/core'
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 { 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({
selector: 'my-edit-vod-transcoding',
@ -42,12 +43,13 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
constructor (
private configService: ConfigService,
private editConfigurationService: EditConfigurationService
private editConfigurationService: EditConfigurationService,
private notifier: Notifier
) { }
ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.editConfigurationService.getVODResolutions()
this.resolutions = this.editConfigurationService.getTranscodingResolutions()
this.checkTranscodingFields()
}
@ -84,6 +86,10 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
return this.editConfigurationService.isTranscodingEnabled(this.form)
}
isHLSEnabled () {
return this.editConfigurationService.isHLSEnabled(this.form)
}
isStudioEnabled () {
return this.editConfigurationService.isStudioEnabled(this.form)
}
@ -92,6 +98,10 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
}
getHLSDisabledClass () {
return { 'disabled-checkbox-extra': !this.isHLSEnabled() }
}
getLocalTranscodingDisabledClass () {
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')
webVideosControl.valueChanges
.subscribe(newValue => {
if (newValue === false && !hlsControl.disabled) {
hlsControl.disable()
}
.subscribe(newValue => {
if (newValue === false && hlsControl.value === false) {
hlsControl.setValue(true)
if (newValue === true && !hlsControl.enabled) {
hlsControl.enable()
}
})
// eslint-disable-next-line max-len
this.notifier.info($localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000)
}
})
hlsControl.valueChanges
.subscribe(newValue => {
if (newValue === false && !webVideosControl.disabled) {
webVideosControl.disable()
}
.subscribe(newValue => {
if (newValue === false && webVideosControl.value === false) {
webVideosControl.setValue(true)
if (newValue === true && !webVideosControl.enabled) {
webVideosControl.enable()
}
})
// eslint-disable-next-line max-len
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
.subscribe(newValue => {
if (newValue === false) {
videoStudioControl.setValue(false)
}
})
.subscribe(newValue => {
if (newValue === false) {
videoStudioControl.setValue(false)
}
})
transcodingControl.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 { VideoShareComponent } from '@app/shared/shared-share-modal/video-share.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'
@Component({

View File

@ -65,13 +65,4 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
hasHlsPlaylist () {
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,
VideoConstant,
VideoDetails as VideoDetailsServerModel,
VideoFile,
VideoFileMetadata,
VideoIncludeType,
VideoPrivacy,
@ -54,6 +55,7 @@ export type CommonVideoParams = {
@Injectable()
export class VideoService {
static BASE_VIDEO_DOWNLOAD_URL = environment.originServerUrl + '/download/videos/generate'
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
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) {
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)

View File

@ -179,17 +179,17 @@ export class UserSubscriptionService {
}
doesSubscriptionExist (nameWithHost: string) {
debugLogger('Running subscription check for %d.', nameWithHost)
debugLogger('Running subscription check for ' + nameWithHost)
if (nameWithHost in this.myAccountSubscriptionCache) {
debugLogger('Found cache for %d.', nameWithHost)
debugLogger('Found cache for ' + nameWithHost)
return of(this.myAccountSubscriptionCache[nameWithHost])
}
this.existsSubject.next(nameWithHost)
debugLogger('Fetching from network for %d.', nameWithHost)
debugLogger('Fetching from network for ' + nameWithHost)
return this.existsObservable.pipe(
filter(existsResult => existsResult[nameWithHost] !== undefined),
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 '_mixins' as *;
.nav-content {
margin-top: 30px;
}
my-global-icon[iconName=shield] {
@include margin-left(10px);
width: 16px;
margin-top: -3px;
}
.advanced-filters-button {
display: flex;
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 {
margin-top: 20px;
}
@ -69,3 +36,13 @@ my-global-icon[iconName=shield] {
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 { 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 { AuthService, HooksService } from '@app/core'
import {
NgbCollapse,
NgbModal,
NgbModalRef,
NgbNav,
NgbNavContent,
NgbNavItem,
@ -15,34 +12,32 @@ import {
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap'
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 { videoRequiresFileToken } from '@root-helpers/video'
import { mapValues } from 'lodash-es'
import { firstValueFrom, of } from 'rxjs'
import { catchError, tap } from 'rxjs/operators'
import { InputTextComponent } from '../shared-forms/input-text.component'
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
import { BytesPipe } from '../shared-main/angular/bytes.pipe'
import { NumberFormatterPipe } from '../shared-main/angular/number-formatter.pipe'
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 { firstValueFrom } from 'rxjs'
import { tap } from 'rxjs/operators'
import { InputTextComponent } from '../../shared-forms/input-text.component'
import { GlobalIconComponent } from '../../shared-icons/global-icon.component'
import { BytesPipe } from '../../shared-main/angular/bytes.pipe'
import { NumberFormatterPipe } from '../../shared-main/angular/number-formatter.pipe'
import { VideoDetails } from '../../shared-main/video/video-details.model'
import { VideoService } from '../../shared-main/video/video.service'
type DownloadType = 'video' | 'subtitles'
type FileMetadata = { [key: string]: { label: string, value: string | number } }
@Component({
selector: 'my-video-download',
templateUrl: './video-download.component.html',
styleUrls: [ './video-download.component.scss' ],
selector: 'my-video-files-download',
templateUrl: './video-files-download.component.html',
styleUrls: [ './video-files-download.component.scss' ],
standalone: true,
imports: [
NgIf,
FormsModule,
GlobalIconComponent,
NgbNav,
NgFor,
NgbNav,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
@ -56,15 +51,16 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
NgClass
]
})
export class VideoDownloadComponent {
@ViewChild('modal', { static: true }) modal: ElementRef
export class VideoFilesDownloadComponent implements OnInit {
@Input({ required: true }) video: VideoDetails
@Input() originalVideoFile: VideoSource
@Input() videoFileToken: string
@Input() videoPassword: string
@Output() downloaded = new EventEmitter<void>()
downloadType: 'direct' | 'torrent' = 'direct'
resolutionId: number | 'original' = -1
subtitleLanguageId: string
activeResolutionId: number | 'original' = -1
videoFileMetadataFormat: FileMetadata
videoFileMetadataVideoStream: FileMetadata | undefined
@ -72,133 +68,50 @@ export class VideoDownloadComponent {
isAdvancedCustomizationCollapsed = true
type: DownloadType = 'video'
videoFileToken: string
originalVideoFile: VideoSource
loaded = false
private activeModal: NgbModalRef
private bytesPipe: BytesPipe
private numbersPipe: NumberFormatterPipe
private video: VideoDetails
private videoCaptions: VideoCaption[]
constructor (
@Inject(LOCALE_ID) private localeId: string,
private modalService: NgbModal,
private authService: AuthService,
private videoService: VideoService,
private videoFileTokenService: VideoFileTokenService,
private hooks: HooksService
private videoService: VideoService
) {
this.bytesPipe = new BytesPipe()
this.numbersPipe = new NumberFormatterPipe(this.localeId)
}
get typeText () {
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 })
ngOnInit () {
if (this.hasFiles()) {
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 () {
if (!this.video.isLocal || !this.authService.isLoggedIn()) return of(undefined)
getVideoFiles () {
if (!this.video) return []
if (this.video.files.length !== 0) return this.video.files
const user = this.authService.getUser()
if (!this.video.isOwnerOrHasSeeAllVideosRight(user)) return of(undefined)
const hls = this.video.getHlsPlaylist()
if (hls) return hls.files
return this.videoService.getSource(this.video.id)
.pipe(catchError(err => {
console.error('Cannot get source file', err)
return of(undefined)
}))
return []
}
// ---------------------------------------------------------------------------
onClose () {
this.video = undefined
this.videoCaptions = undefined
}
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') {
this.resolutionId = resolutionId
this.activeResolutionId = resolutionId
let metadata: VideoFileMetadata
if (this.resolutionId === 'original') {
if (this.activeResolutionId === 'original') {
metadata = this.originalVideoFile.metadata
} else {
const videoFile = this.getVideoFile()
@ -218,22 +131,20 @@ export class VideoDownloadComponent {
this.videoFileMetadataAudioStream = this.getMetadataStream(metadata.streams, 'audio')
}
onSubtitleIdChange (subtitleId: string) {
this.subtitleLanguageId = subtitleId
}
// ---------------------------------------------------------------------------
hasFiles () {
return this.getVideoFiles().length !== 0
}
getVideoFile () {
if (this.resolutionId === 'original') return undefined
if (this.activeResolutionId === 'original') return undefined
const file = this.getVideoFiles()
.find(f => f.resolution.id === this.resolutionId)
.find(f => f.resolution.id === this.activeResolutionId)
if (!file) {
logger.error(`Could not find file with resolution ${this.resolutionId}`)
logger.error(`Could not find file with resolution ${this.activeResolutionId}`)
return undefined
}
@ -241,11 +152,11 @@ export class VideoDownloadComponent {
}
getVideoFileLink () {
const suffix = this.resolutionId === 'original' || this.isConfidentialVideo()
const suffix = this.activeResolutionId === 'original' || this.isConfidentialVideo()
? '?videoFileToken=' + this.videoFileToken
: ''
if (this.resolutionId === 'original') {
if (this.activeResolutionId === 'original') {
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 () {
return this.resolutionId === 'original' || videoRequiresFileToken(this.video)
return this.activeResolutionId === 'original' || videoRequiresFileToken(this.video)
}
switchToType (type: DownloadType) {
this.type = type
}
// ---------------------------------------------------------------------------
hasMetadata () {
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 { LiveStreamInformationComponent } from '../shared-video-live/live-stream-information.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 = {
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
// 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 { 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: string ]: number
}
type Metadata = {
levels: Level[]
}
// ---------------------------------------------------------------------------
// Source handler registration
// ---------------------------------------------------------------------------
@ -126,10 +122,10 @@ export class Html5Hlsjs {
private maxNetworkErrorRecovery = 5
private hls: Hlsjs
private hlsjsConfig: Partial<HlsConfig & { cueHandler: any }> = null
private hlsjsConfig: HLSPluginOptions = null
private _duration: number = null
private metadata: Metadata = null
private metadata: ManifestParsedData = null
private isLive: boolean = null
private dvrDuration: number = null
private edgeMargin: number = null
@ -139,6 +135,8 @@ export class Html5Hlsjs {
error: null
}
private audioMode = false
constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) {
this.vjs = vjs
this.source = source
@ -206,50 +204,14 @@ export class Html5Hlsjs {
return this.vjs.createTimeRanges()
}
// See comment for `initialize` method.
dispose () {
this.videoElement.removeEventListener('play', this.handlers.play)
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()
}
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 }) {
switch (error.code) {
@ -265,11 +227,14 @@ export class Html5Hlsjs {
}
this.hls.destroy()
logger.info('bubbling error up to VIDEOJS')
this.tech.error = () => ({
...error,
message: this._getHumanErrorMsg(error)
})
this.tech.trigger('error')
}
@ -335,16 +300,18 @@ export class Html5Hlsjs {
}
}
// ---------------------------------------------------------------------------
private buildLevelLabel (level: Level) {
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.width) return Math.round(level.width * 9 / 16) + 'p'
if (level.bitrate) return (level.bitrate / 1000) + 'kbps'
return '0'
return this.player.localize('Audio only')
}
private _removeQuality (index: number) {
@ -367,50 +334,61 @@ export class Html5Hlsjs {
label: this.buildLevelLabel(level),
selected: level.id === this.hls.manualLevel,
selectCallback: () => {
this.hls.currentLevel = index
}
selectCallback: () => this.manuallySelectVideoLevel(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({
id: -1,
label: this.player.localize('Auto'),
selected: true,
selectCallback: () => this.hls.currentLevel = -1
selectCallback: () => this.manuallySelectVideoLevel(-1)
})
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 () {
this.hls.startLoad(-1)
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) {
// This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later
this.metadata = data
this._notifyVideoQualities()
}
private _initHlsjs () {
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 ? this._oneLevelObjClone(hlsjsConfigRef) : {}
private initialize () {
this.buildBaseConfig()
if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) {
this.hlsjsConfig.autoStartLoad = false
@ -423,9 +401,10 @@ export class Html5Hlsjs {
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.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data))
@ -446,30 +425,83 @@ export class Html5Hlsjs {
if (this.isLive) this.maxNetworkErrorRecovery = 30
})
this.registerLevelEventSwitch()
this.hls.once(Hlsjs.Events.FRAG_LOADED, () => {
// Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls`
// Ref: https://github.com/videojs/videojs-contrib-hls#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.loadSource(this.source.src)
}
private initialize () {
this._initHlsjs()
private updateToAudioOrVideo (newSource: string, startLevel?: number) {
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 { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
import { SettingsButton } from '../settings/settings-menu-button'
import debug from 'debug'
const debugLogger = debug('peertube:player:p2p-media-loader')
const Plugin = videojs.getPlugin('plugin')
class P2pMediaLoaderPlugin extends Plugin {
@ -56,19 +59,23 @@ class P2pMediaLoaderPlugin extends Plugin {
return
}
// FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
(videojs as any).Html5Hlsjs.addHook('beforeinitialize', (_videojsPlayer: any, hlsjs: any) => {
player.on('hlsjs-initialized', (_: any, { hlsjs, engine }) => {
this.p2pEngine?.removeAllListeners()
this.p2pEngine?.destroy()
clearInterval(this.networkInfoInterval)
this.hlsjs = hlsjs
this.p2pEngine = engine
debugLogger('hls.js initialized, initializing p2p-media-loader plugin', { hlsjs, engine })
player.ready(() => this.initializePlugin())
})
player.src({
type: options.type,
src: options.src
})
player.ready(() => {
this.initializePlugin()
})
}
dispose () {
@ -76,9 +83,7 @@ class P2pMediaLoaderPlugin extends Plugin {
this.p2pEngine?.destroy()
this.hlsjs?.destroy()
this.options.segmentValidator?.destroy();
(videojs as any).Html5Hlsjs?.removeAllHooks()
this.options.segmentValidator?.destroy()
clearInterval(this.networkInfoInterval)
@ -112,8 +117,6 @@ class P2pMediaLoaderPlugin extends Plugin {
private initializePlugin () {
initHlsJsPlayer(this.player, this.hlsjs)
this.p2pEngine = this.options.loader.getEngine()
this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => {
if (navigator.onLine === false) return

View File

@ -3,6 +3,9 @@ import { logger } from '@root-helpers/logger'
import { wait } from '@root-helpers/utils'
import { removeQueryParams } from '@peertube/peertube-core-utils'
import { isSameOrigin } from '../common'
import debug from 'debug'
const debugLogger = debug('peertube:player:segment-validator')
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`)
}
debugLogger(`Validating ${filename} range ${segment.range}`)
const calculatedSha = await this.sha256Hex(segment.data)
if (calculatedSha !== hashShouldBe) {
throw new Error(

View File

@ -4,7 +4,13 @@ import { LiveVideoLatencyMode } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-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 { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder'
@ -47,7 +53,7 @@ export class HLSOptionsBuilder {
'filter:internal.player.p2p-media-loader.options.result',
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 = {
requiresUserAuth: this.options.requiresUserAuth,
@ -58,19 +64,22 @@ export class HLSOptionsBuilder {
redundancyUrlManager,
type: 'application/x-mpegURL',
src: this.options.hls.playlistUrl,
segmentValidator,
loader
segmentValidator
}
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 file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution)
// 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
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
? this.getHLSLiveOptions()
: this.getHLSVODOptions()
@ -194,7 +203,7 @@ export class HLSOptionsBuilder {
capLevelToPlayerSize: true,
autoStartLoad: false,
loader,
loaderBuilder,
...specificLiveOrVODOptions
}

View File

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

View File

@ -42,7 +42,7 @@ class ResolutionMenuButton extends MenuButton {
for (const r of resolutions) {
const label = r.label === '0p'
? this.player().localize('Audio-only')
? this.player().localize('Audio only')
: r.label
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 { 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 { ContextMenuPlugin } from '../shared/context-menu'
import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-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 { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
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 { PlaylistPlugin } from '../shared/playlist/playlist-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 { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
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' {
@ -79,10 +79,10 @@ export interface VideoJSTechHLS extends videojs.Tech {
export interface HlsjsConfigHandlerOptions {
hlsjsConfig?: HlsConfig
levelLabelHandler?: (level: Level) => string
levelLabelHandler?: (level: Level, player: videojs.Player) => string
}
type PeerTubeResolution = {
export type PeerTubeResolution = {
id: number
height?: number
@ -94,21 +94,21 @@ type PeerTubeResolution = {
selectCallback: () => void
}
type VideoJSCaption = {
export type VideoJSCaption = {
label: string
language: string
src: string
automaticallyGenerated: boolean
}
type VideoJSStoryboard = {
export type VideoJSStoryboard = {
url: string
width: number
height: number
interval: number
}
type PeerTubePluginOptions = {
export type PeerTubePluginOptions = {
autoPlayerRatio: {
cssRatioVariable: string
cssPlayerPortraitModeVariable: string
@ -136,14 +136,14 @@ type PeerTubePluginOptions = {
poster: () => string
}
type MetricsPluginOptions = {
export type MetricsPluginOptions = {
mode: () => PlayerMode
metricsUrl: () => string
metricsInterval: () => number
videoUUID: () => string
}
type ContextMenuPluginOptions = {
export type ContextMenuPluginOptions = {
content: () => {
icon?: string
label: string
@ -151,23 +151,23 @@ type ContextMenuPluginOptions = {
}[]
}
type ContextMenuItemOptions = {
export type ContextMenuItemOptions = {
listener: (e: videojs.EventTarget.Event) => void
label: string
}
type StoryboardOptions = {
export type StoryboardOptions = {
url: string
width: number
height: number
interval: number
}
type ChaptersOptions = {
export type ChaptersOptions = {
chapters: VideoChapter[]
}
type PlaylistPluginOptions = {
export type PlaylistPluginOptions = {
elements: VideoPlaylistElement[]
playlist: VideoPlaylist
@ -177,7 +177,7 @@ type PlaylistPluginOptions = {
onItemClicked: (element: VideoPlaylistElement) => void
}
type UpNextPluginOptions = {
export type UpNextPluginOptions = {
timeout: number
next: () => void
@ -186,33 +186,40 @@ type UpNextPluginOptions = {
isSuspended: () => boolean
}
type ProgressBarMarkerComponentOptions = {
export type ProgressBarMarkerComponentOptions = {
timecode: number
}
type NextPreviousVideoButtonOptions = {
export type NextPreviousVideoButtonOptions = {
type: 'next' | 'previous'
handler?: () => void
isDisplayed: () => boolean
isDisabled: () => boolean
}
type PeerTubeLinkButtonOptions = {
export type PeerTubeLinkButtonOptions = {
isDisplayed: () => boolean
shortUUID: () => string
instanceName: string
}
type TheaterButtonOptions = {
export type TheaterButtonOptions = {
isDisplayed: () => boolean
}
type WebVideoPluginOptions = {
export type WebVideoPluginOptions = {
videoFiles: VideoFile[]
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
segmentValidator: SegmentValidator | null
@ -221,8 +228,6 @@ type P2PMediaLoaderPluginOptions = {
p2pEnabled: boolean
loader: P2PMediaLoader
requiresUserAuth: boolean
videoFileToken: () => string
}
@ -233,7 +238,7 @@ export type P2PMediaLoader = {
destroy: () => void
}
type VideoJSPluginOptions = {
export type VideoJSPluginOptions = {
playlist?: PlaylistPluginOptions
peertube: PeerTubePluginOptions
@ -244,7 +249,7 @@ type VideoJSPluginOptions = {
p2pMediaLoader?: P2PMediaLoaderPluginOptions
}
type LoadedQualityData = {
export type LoadedQualityData = {
qualitySwitchCallback: (resolutionId: number, type: 'video') => void
qualityData: {
video: {
@ -255,17 +260,17 @@ type LoadedQualityData = {
}
}
type ResolutionUpdateData = {
export type ResolutionUpdateData = {
auto: boolean
resolutionId: number
id?: number
}
type AutoResolutionUpdateData = {
export type AutoResolutionUpdateData = {
possible: boolean
}
type PlayerNetworkInfo = {
export type PlayerNetworkInfo = {
source: 'web-video' | 'p2p-media-loader'
http: {
@ -288,34 +293,8 @@ type PlayerNetworkInfo = {
bandwidthEstimate?: number
}
type PlaylistItemOptions = {
export type PlaylistItemOptions = {
element: VideoPlaylistElement
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,
PlaylistTracker,
Translations,
VideoFetcher
VideoFetcher,
getBackendUrl
} from './shared'
import { PlayerHTML } from './shared/player-html'
@ -58,7 +59,7 @@ export class PeerTubeEmbed {
private requiresPassword: boolean
constructor (videoWrapperId: string) {
logger.registerServerSending(window.location.origin)
logger.registerServerSending(getBackendUrl())
this.http = new AuthHTTP()
@ -73,7 +74,9 @@ export class PeerTubeEmbed {
try {
this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
} 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 () {
this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
this.translationsPromise = TranslationsManager.getServerTranslations(getBackendUrl(), navigator.language)
this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player')
// Issue when we parsed config from HTML, fallback to API
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())
}
@ -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 (!this.alreadyPlayed) {
this.peertubePlayer.setPoster(window.location.origin + video.previewPath)
this.peertubePlayer.setPoster(getBackendUrl() + video.previewPath)
}
const playlist = this.playlistTracker
@ -351,6 +354,16 @@ export class PeerTubeEmbed {
// ---------------------------------------------------------------------------
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('/')
return urlParts[urlParts.length - 1]
}

View File

@ -5,5 +5,6 @@ export * from './player-html'
export * from './player-options-builder'
export * from './playlist-fetcher'
export * from './playlist-tracker'
export * from './url'
export * from './translations'
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 { Socket } from 'socket.io-client'
import { PlayerHTML } from './player-html'
import { Translations } from './translations'
import { getBackendUrl } from './url'
export class LiveManager {
private liveSocket: Socket
@ -22,7 +23,7 @@ export class LiveManager {
if (!this.liveSocket) {
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) => {

View File

@ -4,6 +4,7 @@ import { PluginInfo, PluginsManager } from '../../../root-helpers'
import { RegisterClientHelpers } from '../../../types'
import { AuthHTTP } from './auth-http'
import { Translations } from './translations'
import { getBackendUrl } from './url'
export class PeerTubePlugin {
@ -83,6 +84,6 @@ export class PeerTubePlugin {
}
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 { Translations } from './translations'
import { VideoFetcher } from './video-fetcher'
import { getBackendUrl } from './url'
export class PlayerOptionsBuilder {
private autoplay: boolean
@ -190,7 +191,7 @@ export class PlayerOptionsBuilder {
videoViewIntervalMs: serverConfig.views.videos.watchingInterval.anonymous,
metricsUrl: serverConfig.openTelemetry.metrics.enabled
? window.location.origin + '/api/v1/metrics/playback'
? getBackendUrl() + '/api/v1/metrics/playback'
: null,
metricsInterval: serverConfig.openTelemetry.metrics.playbackStatsInterval,
@ -204,7 +205,7 @@ export class PlayerOptionsBuilder {
theaterButton: false,
serverUrl: window.location.origin,
serverUrl: getBackendUrl(),
language: navigator.language,
pluginsManager: this.peertubePlugin.getPluginsManager(),
@ -292,9 +293,9 @@ export class PlayerOptionsBuilder {
duration: video.duration,
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,
requiresUserAuth: videoRequiresUserAuth(video),
@ -333,7 +334,7 @@ export class PlayerOptionsBuilder {
if (!storyboards || storyboards.length === 0) return undefined
return {
url: window.location.origin + storyboards[0].storyboardPath,
url: getBackendUrl() + storyboards[0].storyboardPath,
height: storyboards[0].spriteHeight,
width: storyboards[0].spriteWidth,
interval: storyboards[0].spriteDuration
@ -426,7 +427,7 @@ export class PlayerOptionsBuilder {
label: peertubeTranslate(c.language.label, translations),
language: c.language.id,
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 { logger } from '../../../root-helpers'
import { AuthHTTP } from './auth-http'
import { getBackendUrl } from './url'
export class PlaylistFetcher {
@ -68,6 +69,6 @@ export class PlaylistFetcher {
}
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 { PeerTubeServerError } from '../../../types'
import { AuthHTTP } from './auth-http'
import { getBackendUrl } from './url'
export class VideoFetcher {
@ -70,11 +71,11 @@ export class VideoFetcher {
}
private getVideoUrl (id: string) {
return window.location.origin + '/api/v1/videos/' + id
return getBackendUrl() + '/api/v1/videos/' + id
}
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> {
@ -82,7 +83,7 @@ export class VideoFetcher {
}
private getStoryboardsUrl (videoId: string) {
return window.location.origin + '/api/v1/videos/' + videoId + '/storyboards'
return getBackendUrl() + '/api/v1/videos/' + videoId + '/storyboards'
}
private getVideoTokenUrl (id: string) {

View File

@ -9,15 +9,40 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, '../../../')
export default defineConfig(() => {
export default defineConfig(({ mode }) => {
return {
base: '/client/standalone/videos/',
base: mode === 'development'
? ''
: '/client/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: {
alias: [
{ 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') }
],
},
@ -33,6 +58,7 @@ export default defineConfig(() => {
build: {
outDir: resolve(root, 'dist', 'standalone', 'videos'),
emptyOutDir: true,
sourcemap: mode === 'development',
target: [ 'firefox78', 'ios12' ],

View File

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

View File

@ -56,6 +56,11 @@ rates_limit:
# 500 attempts in 10 seconds (to not break crawlers)
window: 10 seconds
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:
token_lifetime:
@ -588,7 +593,7 @@ transcoding:
profile: 'default'
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
240p: false
360p: false
@ -616,6 +621,11 @@ transcoding:
hls:
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:
enabled: false
@ -693,6 +703,7 @@ live:
profile: 'default'
resolutions:
0p: false # Audio only
144p: false
240p: false
360p: false

View File

@ -54,6 +54,11 @@ rates_limit:
# 500 attempts in 10 seconds (to not break crawlers)
window: 10 seconds
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:
token_lifetime:
@ -598,7 +603,7 @@ transcoding:
profile: 'default'
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
240p: false
360p: false
@ -626,6 +631,11 @@ transcoding:
hls:
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:
enabled: false
@ -703,6 +713,7 @@ live:
profile: 'default'
resolutions:
0p: false # Audio only
144p: false
240p: 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 {
AvailableEncoders,
EncoderOptionsBuilder,
@ -8,6 +8,7 @@ import {
} from '@peertube/peertube-models'
import { MutexInterface } from 'async-mutex'
import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
import { Readable } from 'node:stream'
export interface FFmpegCommandWrapperOptions {
availableEncoders?: AvailableEncoders
@ -83,15 +84,19 @@ export class FFmpegCommandWrapper {
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')
// 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,
cwd: this.tmpDirectory
})
for (const input of arrayify(inputs)) {
this.command.input(input)
}
if (this.threads > 0) {
// If we don't set any threads ffmpeg will chose automatically
this.command.outputOption('-threads ' + this.threads)
@ -117,7 +122,10 @@ export class FFmpegCommandWrapper {
this.command.on('start', cmdline => { shellCommand = cmdline })
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)

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 { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
import { presetVOD } from './shared/presets.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 {
private readonly commandWrapper: FFmpegCommandWrapper
@ -10,25 +22,27 @@ export class FFmpegEdition {
this.commandWrapper = new FFmpegCommandWrapper(options)
}
async cutVideo (options: {
inputPath: string
outputPath: string
async cutVideo (options: BaseStudioOptions & {
start?: number
end?: number
}) {
const { inputPath, outputPath } = options
const { videoInputPath, separatedAudioInputPath, outputPath, inputFileMutexReleaser } = options
const mainProbe = await ffprobePromise(inputPath)
const fps = await getVideoStreamFPS(inputPath, mainProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
const mainProbe = await ffprobePromise(videoInputPath)
const fps = await getVideoStreamFPS(videoInputPath, mainProbe)
const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, mainProbe)
const command = this.commandWrapper.buildCommand(inputPath)
const command = this.commandWrapper.buildCommand(this.buildInputs(options), inputFileMutexReleaser)
.output(outputPath)
await presetVOD({
commandWrapper: this.commandWrapper,
input: inputPath,
videoInputPath,
separatedAudioInputPath,
resolution,
videoStreamOnly: false,
fps,
canCopyAudio: false,
canCopyVideo: false
@ -45,10 +59,8 @@ export class FFmpegEdition {
await this.commandWrapper.runCommand()
}
async addWatermark (options: {
inputPath: string
async addWatermark (options: BaseStudioOptions & {
watermarkPath: string
outputPath: string
videoFilters: {
watermarkSizeRatio: number
@ -56,21 +68,23 @@ export class FFmpegEdition {
verticalMarginRatio: number
}
}) {
const { watermarkPath, inputPath, outputPath, videoFilters } = options
const { watermarkPath, videoInputPath, separatedAudioInputPath, outputPath, videoFilters, inputFileMutexReleaser } = options
const videoProbe = await ffprobePromise(inputPath)
const fps = await getVideoStreamFPS(inputPath, videoProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
const videoProbe = await ffprobePromise(videoInputPath)
const fps = await getVideoStreamFPS(videoInputPath, 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)
command.input(watermarkPath)
await presetVOD({
commandWrapper: this.commandWrapper,
input: inputPath,
videoInputPath,
separatedAudioInputPath,
resolution,
videoStreamOnly: false,
fps,
canCopyAudio: true,
canCopyVideo: false
@ -103,27 +117,24 @@ export class FFmpegEdition {
await this.commandWrapper.runCommand()
}
async addIntroOutro (options: {
inputPath: string
async addIntroOutro (options: BaseStudioOptions & {
introOutroPath: string
outputPath: string
type: 'intro' | 'outro'
}) {
const { introOutroPath, inputPath, outputPath, type } = options
const { introOutroPath, videoInputPath, separatedAudioInputPath, outputPath, type, inputFileMutexReleaser } = options
const mainProbe = await ffprobePromise(inputPath)
const fps = await getVideoStreamFPS(inputPath, mainProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
const mainProbe = await ffprobePromise(videoInputPath)
const fps = await getVideoStreamFPS(videoInputPath, mainProbe)
const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, mainProbe)
const mainHasAudio = await hasAudioStream(separatedAudioInputPath || videoInputPath, mainProbe)
const introOutroProbe = await ffprobePromise(introOutroPath)
const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
const command = this.commandWrapper.buildCommand(inputPath)
const command = this.commandWrapper.buildCommand([ ...this.buildInputs(options), introOutroPath ], inputFileMutexReleaser)
.output(outputPath)
command.input(introOutroPath)
if (!introOutroHasAudio && mainHasAudio) {
const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
@ -134,8 +145,12 @@ export class FFmpegEdition {
await presetVOD({
commandWrapper: this.commandWrapper,
input: inputPath,
videoInputPath,
separatedAudioInputPath,
resolution,
videoStreamOnly: false,
fps,
canCopyAudio: false,
canCopyVideo: false
@ -236,4 +251,11 @@ export class FFmpegEdition {
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 { FfprobeData, FilterSpecification } from 'fluent-ffmpeg'
import { VideoResolution } from '@peertube/peertube-models'
import { FfmpegCommand, FfprobeData, FilterSpecification } from 'fluent-ffmpeg'
import { join } from 'path'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
import { StreamType, buildStreamSuffix, getScaleFilter } from './ffmpeg-utils.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 {
private readonly commandWrapper: FFmpegCommandWrapper
@ -12,132 +37,84 @@ export class FFmpegLive {
this.commandWrapper = new FFmpegCommandWrapper(options)
}
async getLiveTranscodingCommand (options: {
inputUrl: string
async getLiveTranscodingCommand (options: LiveTranscodingOptions) {
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 {
inputUrl,
outPath,
toTranscode,
bitrate,
masterPlaylistName,
ratio,
hasAudio,
probe
splitAudioAndVideo
} = options
const command = this.commandWrapper.buildCommand(inputUrl)
const varStreamMap: string[] = []
const complexFilter: FilterSpecification[] = [
{
inputs: '[v:0]',
filter: 'split',
options: toTranscode.length,
outputs: toTranscode.map(t => `vtemp${t.resolution}`)
}
]
let varStreamMap: string[] = []
command.outputOption('-sc_threshold 0')
addDefaultEncoderGlobalParams(command)
for (let i = 0; i < toTranscode.length; i++) {
const streamMap: string[] = []
const { resolution, fps } = toTranscode[i]
// Audio input only or audio output only
if (this.isAudioInputOrOutputOnly(options)) {
const result = await this.buildTranscodingStream({
...options,
const baseEncoderBuilderParams = {
input: inputUrl,
command,
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,
canCopyVideo: true,
varStreamMap = varStreamMap.concat(result.varStreamMap)
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,
inputRatio: ratio,
inputProbe: probe,
let complexFilter: FilterSpecification[] = [
{
inputs: '[v:0]',
filter: 'split',
options: toTranscodeWithoutAudioOnly.length,
outputs: toTranscodeWithoutAudioOnly.map(t => `vtemp${t.resolution}`)
}
]
resolution,
fps,
let alreadyProcessedAudio = false
streamNum: i,
videoType: 'live' as 'live'
}
for (let i = 0; i < toTranscodeWithoutAudioOnly.length; i++) {
let streamMap: string[] = []
{
const streamType: StreamType = 'video'
const { resolution, fps } = toTranscodeWithoutAudioOnly[i]
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
if (!builderResult) {
throw new Error('No available live video encoder found')
for (const streamType of [ 'audio' as 'audio', 'video' as 'video' ]) {
if (streamType === 'audio') {
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}]`)
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')
if (streamMap.length !== 0) {
varStreamMap.push(streamMap.join(','))
}
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 })
command.outputOption('-var_stream_map', varStreamMap.join(' '))
@ -145,6 +122,101 @@ export class FFmpegLive {
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: {
inputUrl: string
outPath: string
@ -167,6 +239,8 @@ export class FFmpegLive {
return command
}
// ---------------------------------------------------------------------------
private addDefaultLiveHLSParams (options: {
outPath: string
masterPlaylistName: string

View File

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

View File

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

View File

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

View File

@ -14,6 +14,18 @@ export interface ActivityIconObject {
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 = {
type: 'Link'
mediaType: 'video/mp4' | 'video/webm' | 'video/ogg' | 'audio/mp4'
@ -22,8 +34,12 @@ export type ActivityVideoUrlObject = {
width: number | null
size: number
fps: number
attachment: ActivityVideoUrlObjectAttachment[]
}
// ---------------------------------------------------------------------------
export type ActivityPlaylistSegmentHashesObject = {
type: 'Link'
name: 'sha256'

View File

@ -106,6 +106,7 @@ export const serverFilterHookObject = {
// Filter result used to check if video/torrent download is allowed
'filter:api.download.video.allowed.result': true,
'filter:api.download.generated-video.allowed.result': true,
'filter:api.download.torrent.allowed.result': true,
// 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 {
input: {
videoFileUrl: string
separatedAudioFileUrl: string[]
}
output: {
@ -27,11 +28,13 @@ export interface RunnerJobVODWebVideoTranscodingPayload {
export interface RunnerJobVODHLSTranscodingPayload {
input: {
videoFileUrl: string
separatedAudioFileUrl: string[]
}
output: {
resolution: number
fps: number
separatedAudio: boolean
}
}
@ -50,6 +53,7 @@ export interface RunnerJobVODAudioMergeTranscodingPayload {
export interface RunnerJobStudioTranscodingPayload {
input: {
videoFileUrl: string
separatedAudioFileUrl: string[]
}
tasks: VideoStudioTaskPayload[]

View File

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

View File

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

View File

@ -1,3 +1,5 @@
export * from './video-file-metadata.model.js'
export * from './video-file.model.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
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 {
static getCustomConfigResolutions (enabled: boolean, with0p = false) {
static getConfigResolutions (enabled: boolean, with0p = false) {
return {
'0p': enabled && with0p,
'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) {
@ -211,19 +225,27 @@ export class ConfigCommand extends AbstractCommand {
enableLive (options: {
allowReplay?: boolean
resolutions?: 'min' | 'max' | number[] // default 'min'
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({
newConfig: {
live: {
enabled: true,
allowReplay: allowReplay ?? true,
allowReplay,
maxDuration,
transcoding: {
enabled: transcoding ?? true,
resolutions: ConfigCommand.getCustomConfigResolutions(resolutions === 'max')
enabled: transcoding,
alwaysTranscodeOriginalResolution,
resolutions: Array.isArray(resolutions)
? ConfigCommand.getCustomConfigResolutions(resolutions)
: ConfigCommand.getConfigResolutions(resolutions === 'max')
}
}
}
@ -246,10 +268,14 @@ export class ConfigCommand extends AbstractCommand {
enableTranscoding (options: {
webVideo?: boolean // default true
hls?: boolean // default true
with0p?: 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({
newConfig: {
@ -262,25 +288,39 @@ export class ConfigCommand extends AbstractCommand {
allowAudioFiles: true,
allowAdditionalExtensions: true,
resolutions: ConfigCommand.getCustomConfigResolutions(true, with0p),
resolutions: Array.isArray(resolutions)
? ConfigCommand.getCustomConfigResolutions(resolutions)
: ConfigCommand.getConfigResolutions(resolutions === 'max', with0p),
webVideos: {
enabled: webVideo
},
hls: {
enabled: hls
enabled: hls,
splitAudioAndVideo
}
}
}
})
}
setTranscodingConcurrency (concurrency: number) {
return this.updateExistingConfig({
newConfig: {
transcoding: {
concurrency
}
}
})
}
enableMinimumTranscoding (options: {
webVideo?: boolean // default true
hls?: boolean // default true
splitAudioAndVideo?: 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({
newConfig: {
@ -294,7 +334,7 @@ export class ConfigCommand extends AbstractCommand {
allowAdditionalExtensions: true,
resolutions: {
...ConfigCommand.getCustomConfigResolutions(false),
...ConfigCommand.getConfigResolutions(false),
'240p': true
},
@ -303,7 +343,8 @@ export class ConfigCommand extends AbstractCommand {
enabled: webVideo
},
hls: {
enabled: hls
enabled: hls,
splitAudioAndVideo
}
}
}

View File

@ -1,7 +1,7 @@
import { waitJobs } from './jobs.js'
import { PeerTubeServer } from './server.js'
async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
export async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
await Promise.all([
server1.follows.follow({ hosts: [ server2.url ] }),
server2.follows.follow({ hosts: [ server1.url ] })
@ -9,12 +9,18 @@ async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
// Wait request propagation
await waitJobs([ server1, server2 ])
return true
}
// ---------------------------------------------------------------------------
export function followAll (servers: PeerTubeServer[]) {
const p: Promise<void>[] = []
export {
doubleFollow
for (const server of servers) {
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
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) {
@ -45,7 +45,7 @@ async function waitJobs (
pendingRequests = true
if (process.env.DEBUG) {
console.log(jobs)
console.log(`${new Date().toISOString()}`, jobs)
}
}
})
@ -59,7 +59,7 @@ async function waitJobs (
pendingRequests = true
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
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) {
const servers = arrayify(serversArg)
for (const server of servers) {
await server.users.updateMyAvatar({ fixture: 'avatar.png', token })
}
return Promise.all(
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'
export function setDefaultVideoChannel (servers: PeerTubeServer[]) {
const tasks: Promise<any>[] = []
for (const server of servers) {
const p = server.users.getMyInfo()
.then(user => { server.store.channel = user.videoChannels[0] })
tasks.push(p)
}
return Promise.all(tasks)
return Promise.all(
servers.map(s => {
return s.users.getMyInfo()
.then(user => { s.store.channel = user.videoChannels[0] })
})
)
}
export async function setDefaultChannelAvatar (serversArg: PeerTubeServer | PeerTubeServer[], channelName: string = 'root_channel') {
const servers = arrayify(serversArg)
for (const server of servers) {
await server.channels.updateImage({ channelName, fixture: 'avatar.png', type: 'avatar' })
}
return Promise.all(
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 & {
videoId: number | string
shouldHaveError: boolean
fixtureName?: string
}) {
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 { PeerTubeServer } from '../server/server.js'
function sendRTMPStream (options: {
export function sendRTMPStream (options: {
rtmpBaseUrl: string
streamKey: string
fixtureName?: string // default video_short.mp4
@ -49,7 +49,7 @@ function sendRTMPStream (options: {
return command
}
function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) {
export function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) {
return new Promise<void>((res, rej) => {
command.on('error', 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
try {
@ -76,31 +76,39 @@ async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: b
if (!shouldHaveError && error) throw error
}
async function stopFfmpeg (command: FfmpegCommand) {
export async function stopFfmpeg (command: FfmpegCommand) {
command.kill('SIGINT')
await wait(500)
}
async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) {
export async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) {
for (const server of servers) {
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) {
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) {
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 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)
}
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)
}
async findFull (options: OverrideCommandOptions & {
name: string
}) {
const { uuid } = await this.find(options)
return this.get({ id: uuid })
}
// ---------------------------------------------------------------------------
update (options: OverrideCommandOptions & {
@ -662,4 +670,25 @@ export class VideosCommand extends AbstractCommand {
endVideoResumableUpload (options: Parameters<AbstractCommand['endResumableUpload']>[0]) {
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 './debug.js'
import './follows.js'
import './generate-download.js'
import './jobs.js'
import './live.js'
import './logs.js'

View File

@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { basename } from 'path'
import {
HttpStatusCode,
HttpStatusCodeType,
@ -12,7 +11,6 @@ import {
VideoPrivacy,
VideoStudioTaskIntro
} from '@peertube/peertube-models'
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
import {
cleanupTests,
createSingleServer,
@ -25,6 +23,8 @@ import {
VideoStudioCommand,
waitJobs
} 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'
@ -66,7 +66,7 @@ describe('Test managing runners', function () {
registrationToken = data[0].registrationToken
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.enableRemoteTranscoding()
await server.config.enableRemoteStudio()
@ -452,7 +452,7 @@ describe('Test managing runners', function () {
const { uuid } = await server.videos.quickUpload({ name: 'video studio' })
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.videoStudio.createEditionTasks({

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { getAllFiles } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRole, VideoDetails, VideoPrivacy } from '@peertube/peertube-models'
import { getAllFiles, getHLS } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRole, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
import {
cleanupTests,
createMultipleServers,
@ -73,9 +73,14 @@ describe('Test videos files', function () {
let remoteHLSFileId: number
let remoteWebVideoFileId: number
let splittedHLSId: string
let hlsWithAudioId: string
before(async function () {
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' })
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' })
@ -103,22 +108,43 @@ describe('Test videos files', function () {
const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' })
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' })
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' })
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)
@ -168,9 +194,6 @@ describe('Test videos files', 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.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.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 () {

View File

@ -204,7 +204,7 @@ describe('Test video sources API validator', function () {
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 })
})
})

View File

@ -1,8 +1,9 @@
import './live-constraints.js'
import './live-fast-restream.js'
import './live-socket-messages.js'
import './live-privacy-update.js'
import './live-permanent.js'
import './live-privacy-update.js'
import './live-rtmps.js'
import './live-save-replay.js'
import './live-socket-messages.js'
import './live-audio-or-video-only.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 */
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 {
PeerTubeServer,
cleanupTests, createMultipleServers,
@ -38,14 +38,14 @@ describe('Test live constraints', function () {
return uuid
}
async function checkSaveReplay (videoId: string, resolutions = [ 720 ]) {
async function checkSaveReplay (videoId: string, savedResolutions?: number[]) {
for (const server of servers) {
const video = await server.videos.get({ id: videoId })
expect(video.isLive).to.be.false
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 }) {
@ -100,7 +100,7 @@ describe('Test live constraints', function () {
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
await waitJobs(servers)
await checkSaveReplay(userVideoLiveoId)
await checkSaveReplay(userVideoLiveoId, [ VideoResolution.H_720P ])
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
@ -136,7 +136,7 @@ describe('Test live constraints', function () {
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
await waitJobs(servers)
await checkSaveReplay(userVideoLiveoId)
await checkSaveReplay(userVideoLiveoId, [ VideoResolution.H_720P ])
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
@ -223,7 +223,7 @@ describe('Test live constraints', function () {
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
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 })
expect(session.error).to.equal(LiveVideoError.DURATION_EXCEEDED)

View File

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

View File

@ -155,7 +155,7 @@ describe('Save replay setting', function () {
maxDuration: -1,
transcoding: {
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 () {
this.timeout(120000)
const liveDetails = await servers[0].videos.get({ id: liveVideoUUID })
await stopFfmpeg(ffmpegCommand)
await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID)
await waitJobs(servers)
const video = await findExternalSavedVideo(servers[0], liveDetails)
const video = await findExternalSavedVideo(servers[0], liveVideoUUID)
expect(video).to.exist
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 () {
this.timeout(120000)
const liveDetails = await servers[0].videos.get({ id: liveVideoUUID })
await stopFfmpeg(ffmpegCommand)
await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID)
await waitJobs(servers)
const video = await findExternalSavedVideo(servers[0], liveDetails)
const video = await findExternalSavedVideo(servers[0], liveVideoUUID)
expect(video).to.exist
for (const server of servers) {
@ -569,7 +565,7 @@ describe('Save replay setting', function () {
replaySettings: { privacy: VideoPrivacy.PUBLIC }
})
const replay = await findExternalSavedVideo(servers[0], liveDetails)
const replay = await findExternalSavedVideo(servers[0], liveDetails.uuid)
expect(replay).to.exist
for (const videoId of [ liveVideoUUID, replay.uuid ]) {
@ -591,7 +587,7 @@ describe('Save replay setting', function () {
replaySettings: { privacy: VideoPrivacy.PUBLIC }
})
const replay = await findExternalSavedVideo(servers[0], liveDetails)
const replay = await findExternalSavedVideo(servers[0], liveDetails.uuid)
expect(replay).to.not.exist
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 */
import { expect } from 'chai'
import { basename, join } from 'path'
import { getAllFiles, wait } from '@peertube/peertube-core-utils'
import { ffprobePromise, getVideoStream } from '@peertube/peertube-ffmpeg'
import { ffprobePromise } from '@peertube/peertube-ffmpeg'
import {
HttpStatusCode,
LiveVideo,
@ -12,6 +10,7 @@ import {
VideoCommentPolicy,
VideoDetails,
VideoPrivacy,
VideoResolution,
VideoState,
VideoStreamingPlaylistType
} from '@peertube/peertube-models'
@ -35,6 +34,8 @@ import {
import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js'
import { testLiveVideoResolutions } from '@tests/shared/live.js'
import { SQLCommand } from '@tests/shared/sql-command.js'
import { expect } from 'chai'
import { basename, join } from 'path'
describe('Test live', function () {
let servers: PeerTubeServer[] = []
@ -399,38 +400,22 @@ describe('Test live', function () {
}
function updateConf (resolutions: number[]) {
return servers[0].config.updateExistingConfig({
newConfig: {
live: {
enabled: true,
allowReplay: true,
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)
}
}
}
}
return servers[0].config.enableLive({
allowReplay: true,
resolutions,
transcoding: true,
maxDuration: -1
})
}
before(async function () {
await updateConf([])
sqlCommandServer1 = new SQLCommand(servers[0])
})
it('Should enable transcoding without additional resolutions', async function () {
this.timeout(120000)
await updateConf([])
liveVideoId = await createLiveWrapper(false)
const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId })
@ -449,18 +434,6 @@ describe('Test live', function () {
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 () {
this.timeout(240000)
@ -541,15 +514,17 @@ describe('Test live', function () {
await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
const maxBitrateLimits = {
720: 6500 * 1000, // 60FPS
360: 1250 * 1000,
240: 700 * 1000
720: 6350 * 1000, // 60FPS
360: 1100 * 1000,
240: 600 * 1000,
0: 170 * 1000
}
const minBitrateLimits = {
720: 4800 * 1000,
360: 1000 * 1000,
240: 550 * 1000
720: 4650 * 1000,
360: 850 * 1000,
240: 400 * 1000,
0: 100 * 1000
}
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.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)
expect(file).to.exist
@ -578,6 +554,8 @@ describe('Test live', function () {
if (resolution >= 720) {
expect(file.fps).to.be.approximately(60, 10)
} else if (resolution === VideoResolution.H_NOVIDEO) {
expect(file.fps).to.equal(0)
} else {
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 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.at.least(minBitrateLimits[videoStream.height])
expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[resolution])
expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[resolution])
await makeRawRequest({ url: file.torrentUrl, 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 hlsFiles = video.streamingPlaylists[0].files
expect(video.files).to.have.lengthOf(0)
expect(hlsFiles).to.have.lengthOf(resolutions.length)
const resolutionsWithAudio = [ VideoResolution.H_NOVIDEO, ...resolutions ]
// eslint-disable-next-line @typescript-eslint/require-array-sort-compare
expect(getAllFiles(video).map(f => f.resolution.id).sort()).to.deep.equal(resolutions)
expect(video.files).to.have.lengthOf(0)
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 () {
@ -677,9 +655,9 @@ describe('Test live', function () {
const hlsFiles = video.streamingPlaylists[0].files
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 () {

View File

@ -451,14 +451,12 @@ describe('Test user notifications', function () {
await waitJobs(servers)
await servers[1].live.waitUntilPublished({ videoId: shortUUID })
const liveDetails = await servers[1].videos.get({ id: shortUUID })
await stopFfmpeg(ffmpegCommand)
await servers[1].live.waitUntilWaiting({ videoId: shortUUID })
await waitJobs(servers)
const video = await findExternalSavedVideo(servers[1], liveDetails)
const video = await findExternalSavedVideo(servers[1], shortUUID)
expect(video).to.exist
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 */
import { expect } from 'chai'
import { HttpStatusCode, LiveVideoCreate, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import { HttpStatusCode, LiveVideoCreate, VideoPrivacy } from '@peertube/peertube-models'
import {
cleanupTests,
createMultipleServers,
@ -23,6 +22,7 @@ import { expectStartWith } from '@tests/shared/checks.js'
import { testLiveVideoResolutions } from '@tests/shared/live.js'
import { MockObjectStorageProxy } from '@tests/shared/mock-servers/mock-object-storage.js'
import { SQLCommand } from '@tests/shared/sql-command.js'
import { expect } from 'chai'
async function createLive (server: PeerTubeServer, permanent: boolean) {
const attributes: LiveVideoCreate = {
@ -118,7 +118,7 @@ describe('Object storage for lives', function () {
let videoUUID: string
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)
})
@ -157,10 +157,10 @@ describe('Object storage for lives', function () {
})
describe('With live transcoding', function () {
const resolutions = [ 720, 480, 360, 240, 144 ]
const resolutions = [ VideoResolution.H_720P, VideoResolution.H_240P ]
before(async function () {
await servers[0].config.enableLive({ transcoding: true })
await servers[0].config.enableLive({ transcoding: true, resolutions })
})
describe('Normal replay', function () {
@ -195,7 +195,8 @@ describe('Object storage for lives', function () {
await waitUntilLiveReplacedByReplayOnAllServers(servers, videoUUIDNonPermanent)
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 () {
@ -235,10 +236,10 @@ describe('Object storage for lives', function () {
await waitUntilLiveWaitingOnAllServers(servers, videoUUIDPermanent)
await waitJobs(servers)
const videoLiveDetails = await servers[0].videos.get({ id: videoUUIDPermanent })
const replay = await findExternalSavedVideo(servers[0], videoLiveDetails)
const replay = await findExternalSavedVideo(servers[0], videoUUIDPermanent)
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 () {

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
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 {
cleanupTests,
@ -300,7 +300,7 @@ describe('Object storage for video static file privacy', function () {
server,
videoUUID: privateVideoUUID,
videoFileToken,
resolutions: [ 240, 720 ],
resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ],
isLive: false
})
})
@ -491,7 +491,7 @@ describe('Object storage for video static file privacy', function () {
server,
videoUUID: permanentLiveId,
videoFileToken,
resolutions: [ 720 ],
resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ],
isLive: true
})
@ -513,8 +513,7 @@ describe('Object storage for video static file privacy', function () {
await server.live.waitUntilWaiting({ videoId: permanentLiveId })
await waitJobs([ server ])
const live = await server.videos.getWithToken({ id: permanentLiveId })
const replayFromList = await findExternalSavedVideo(server, live)
const replayFromList = await findExternalSavedVideo(server, permanentLiveId)
const replay = await server.videos.getWithToken({ id: replayFromList.id })
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 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) {
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 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) {
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.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) {
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)
expect(fps).to.be.below(70)
if (resolution === 0) {
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 setDefaultVideoChannel([ server ])
await server.config.enableTranscoding({ hls: true, webVideo: true })
await server.config.enableTranscoding({ hls: false, webVideo: true })
await server.config.enableRemoteTranscoding()
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 () {
await servers[0].runnerJobs.cancelAllJobs()
await servers[0].config.enableTranscoding({ hls: true, webVideo: false })
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
await waitJobs(servers)
@ -397,16 +399,18 @@ describe('Test runner VOD transcoding', function () {
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 })
expect(availableJobs).to.have.lengthOf(9)
expect(availableJobs).to.have.lengthOf(5)
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')
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 () {
@ -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 })
expect(availableJobs).to.have.lengthOf(7)
expect(availableJobs).to.have.lengthOf(4)
for (const resolution of [ 360, 240, 144 ]) {
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

View File

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

View File

@ -1,7 +1,5 @@
/* 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 {
cleanupTests,
@ -11,6 +9,8 @@ import {
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { dateIsValid } from '@tests/shared/checks.js'
import { expect } from 'chai'
describe('Test jobs', function () {
let servers: PeerTubeServer[]
@ -101,12 +101,13 @@ describe('Test jobs', function () {
{
const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' })
// waiting includes waiting-children
expect(body.data).to.have.lengthOf(4)
// root transcoding
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)
}
})

View File

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

View File

@ -39,7 +39,12 @@ async function checkFilesInObjectStorage (objectStorage: ObjectStorageCommand, v
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 videoUUID: string
let publishedAt: string
@ -73,6 +78,7 @@ function runTests (enableObjectStorage: boolean) {
publishedAt = video.publishedAt as string
await servers[0].config.enableTranscoding()
await servers[0].config.setTranscodingConcurrency(concurrency)
})
it('Should generate HLS', async function () {
@ -164,7 +170,7 @@ function runTests (enableObjectStorage: boolean) {
newConfig: {
transcoding: {
enabled: true,
resolutions: ConfigCommand.getCustomConfigResolutions(false),
resolutions: ConfigCommand.getConfigResolutions(false),
webVideos: {
enabled: true
@ -200,7 +206,7 @@ function runTests (enableObjectStorage: boolean) {
newConfig: {
transcoding: {
enabled: true,
resolutions: ConfigCommand.getCustomConfigResolutions(true),
resolutions: ConfigCommand.getConfigResolutions(true),
webVideos: {
enabled: true
@ -255,13 +261,18 @@ function runTests (enableObjectStorage: boolean) {
describe('Test create transcoding jobs from API', function () {
describe('On filesystem', function () {
runTests(false)
})
for (const concurrency of [ 1, 2 ]) {
describe('With concurrency ' + concurrency, function () {
describe('On object storage', function () {
if (areMockObjectStorageTestsDisabled()) return
describe('On filesystem', function () {
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 () {
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[] = []
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 () {
this.timeout(120000)
@ -112,41 +128,18 @@ describe('Test HLS videos', function () {
await doubleFollow(servers[0], servers[1])
})
describe('With Web Video & HLS enabled', function () {
runTestSuite(false)
})
for (const concurrency of [ 1, 2 ]) {
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 () {
await servers[0].config.updateExistingConfig({
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
}
}
}
describe('With only HLS enabled', function () {
runTestSuite({ hlsOnly: true, concurrency })
})
})
runTestSuite(true)
})
}
describe('With object storage enabled', function () {
if (areMockObjectStorageTestsDisabled()) return
@ -163,7 +156,11 @@ describe('Test HLS videos', function () {
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 () {
await objectStorage.cleanupMock()

View File

@ -1,6 +1,7 @@
export * from './audio-only.js'
export * from './create-transcoding.js'
export * from './hls.js'
export * from './split-audio-and-video.js'
export * from './transcoder.js'
export * from './update-while-transcoding.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