mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2024-09-11 13:37:10 +03:00
Separate HLS audio and video streams
Allows: * The HLS player to propose an "Audio only" resolution * The live to output an "Audio only" resolution * The live to ingest and output an "Audio only" stream This feature is under a config for VOD videos and is enabled by default for lives In the future we can imagine: * To propose multiple audio streams for a specific video * To ingest an audio only VOD and just output an audio only "video" (the player would play the audio file and PeerTube would not generate additional resolutions) This commit introduce a new way to download videos: * Add "/download/videos/generate/:videoId" endpoint where PeerTube can mux an audio only and a video only file to a mp4 container * The download client modal introduces a new default panel where the user can choose resolutions it wants to download
This commit is contained in:
parent
e77ba2dfbc
commit
816f346a60
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
@ -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:
|
||||
|
||||
```
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
})
|
||||
|
||||
|
@ -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,
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
|
@ -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({
|
||||
|
@ -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 []
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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]),
|
||||
|
@ -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>
|
@ -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
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
}
|
||||
|
1
client/src/standalone/videos/.env.development
Normal file
1
client/src/standalone/videos/.env.development
Normal file
@ -0,0 +1 @@
|
||||
VITE_BACKEND_URL="http://localhost:9000"
|
@ -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]
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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) => {
|
||||
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}))
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
3
client/src/standalone/videos/shared/url.ts
Normal file
3
client/src/standalone/videos/shared/url.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function getBackendUrl () {
|
||||
return (import.meta as any).env.VITE_BACKEND_URL || window.location.origin
|
||||
}
|
@ -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) {
|
||||
|
@ -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' ],
|
||||
|
||||
|
@ -28,9 +28,6 @@
|
||||
],
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"hls.js": [
|
||||
"node_modules/hls.js/dist/hls.light"
|
||||
],
|
||||
"video.js": [
|
||||
"node_modules/video.js/core"
|
||||
],
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
26
packages/ffmpeg/src/ffmpeg-container.ts
Normal file
26
packages/ffmpeg/src/ffmpeg-container.ts
Normal 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 })
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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')
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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[]
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 =
|
||||
|
@ -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'
|
||||
|
@ -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]
|
@ -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]
|
@ -22,4 +22,7 @@ export interface VideoFile {
|
||||
metadataUrl?: string
|
||||
|
||||
magnetUri: string | null
|
||||
|
||||
hasAudio: boolean
|
||||
hasVideo: boolean
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 }))
|
||||
)
|
||||
}
|
||||
|
@ -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' }))
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
136
packages/tests/src/api/check-params/generate-download.ts
Normal file
136
packages/tests/src/api/check-params/generate-download.ts
Normal 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 ])
|
||||
})
|
||||
})
|
@ -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'
|
||||
|
@ -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({
|
||||
|
@ -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 () {
|
||||
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
@ -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'
|
||||
|
236
packages/tests/src/api/live/live-audio-or-video-only.ts
Normal file
236
packages/tests/src/api/live/live-audio-or-video-only.ts
Normal 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)
|
||||
})
|
||||
})
|
@ -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)
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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)
|
||||
|
@ -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 () {
|
||||
|
@ -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' })
|
||||
|
@ -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 () {
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
@ -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)
|
||||
|
@ -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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
@ -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()
|
||||
|
@ -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'
|
||||
|
175
packages/tests/src/api/transcoding/split-audio-and-video.ts
Normal file
175
packages/tests/src/api/transcoding/split-audio-and-video.ts
Normal 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
Loading…
Reference in New Issue
Block a user