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
|
@ -193,7 +193,7 @@ npm run dev
|
||||||
### Embed
|
### Embed
|
||||||
|
|
||||||
The embed is a standalone application built using Vite.
|
The embed is a standalone application built using Vite.
|
||||||
The generated files (HTML entrypoint and multiple JS and CSS files) are served by the PeerTube server (behind `localhost:9000/videos/embed/:videoUUID` or `localhost:9000/video-playlists/embed/:playlistUUID`).
|
The generated files (HTML entrypoint and multiple JS and CSS files) are served by the Vite server (behind `localhost:5173/videos/embed/:videoUUID` or `localhost:5173/video-playlists/embed/:playlistUUID`).
|
||||||
The following command will compile embed files and run the PeerTube server:
|
The following command will compile embed files and run the PeerTube server:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { remove } from 'fs-extra/esm'
|
import { pick } from '@peertube/peertube-core-utils'
|
||||||
import { join } from 'path'
|
|
||||||
import { FFmpegEdition, FFmpegLive, FFmpegVOD, getDefaultAvailableEncoders, getDefaultEncodersToTry } from '@peertube/peertube-ffmpeg'
|
import { FFmpegEdition, FFmpegLive, FFmpegVOD, getDefaultAvailableEncoders, getDefaultEncodersToTry } from '@peertube/peertube-ffmpeg'
|
||||||
import { RunnerJob, RunnerJobPayload } from '@peertube/peertube-models'
|
import { RunnerJob, RunnerJobPayload } from '@peertube/peertube-models'
|
||||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||||
import { PeerTubeServer } from '@peertube/peertube-server-commands'
|
import { PeerTubeServer } from '@peertube/peertube-server-commands'
|
||||||
|
import { remove } from 'fs-extra/esm'
|
||||||
|
import { join } from 'path'
|
||||||
import { ConfigManager, downloadFile, logger } from '../../../shared/index.js'
|
import { ConfigManager, downloadFile, logger } from '../../../shared/index.js'
|
||||||
import { getWinstonLogger } from './winston-logger.js'
|
import { getWinstonLogger } from './winston-logger.js'
|
||||||
|
|
||||||
|
@ -35,6 +36,18 @@ export async function downloadInputFile (options: {
|
||||||
return destination
|
return destination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadSeparatedAudioFileIfNeeded (options: {
|
||||||
|
urls: string[]
|
||||||
|
job: JobWithToken
|
||||||
|
runnerToken: string
|
||||||
|
}) {
|
||||||
|
const { urls } = options
|
||||||
|
|
||||||
|
if (!urls || urls.length === 0) return undefined
|
||||||
|
|
||||||
|
return downloadInputFile({ url: urls[0], ...pick(options, [ 'job', 'runnerToken' ]) })
|
||||||
|
}
|
||||||
|
|
||||||
export function scheduleTranscodingProgress (options: {
|
export function scheduleTranscodingProgress (options: {
|
||||||
server: PeerTubeServer
|
server: PeerTubeServer
|
||||||
runnerToken: string
|
runnerToken: string
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { FSWatcher, watch } from 'chokidar'
|
|
||||||
import { FfmpegCommand } from 'fluent-ffmpeg'
|
|
||||||
import { ensureDir, remove } from 'fs-extra/esm'
|
|
||||||
import { basename, join } from 'path'
|
|
||||||
import { wait } from '@peertube/peertube-core-utils'
|
import { wait } from '@peertube/peertube-core-utils'
|
||||||
import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '@peertube/peertube-ffmpeg'
|
import {
|
||||||
|
ffprobePromise,
|
||||||
|
getVideoStreamBitrate,
|
||||||
|
getVideoStreamDimensionsInfo,
|
||||||
|
hasAudioStream,
|
||||||
|
hasVideoStream
|
||||||
|
} from '@peertube/peertube-ffmpeg'
|
||||||
import {
|
import {
|
||||||
LiveRTMPHLSTranscodingSuccess,
|
LiveRTMPHLSTranscodingSuccess,
|
||||||
LiveRTMPHLSTranscodingUpdatePayload,
|
LiveRTMPHLSTranscodingUpdatePayload,
|
||||||
|
@ -12,6 +14,10 @@ import {
|
||||||
ServerErrorCode
|
ServerErrorCode
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||||
|
import { FSWatcher, watch } from 'chokidar'
|
||||||
|
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||||
|
import { ensureDir, remove } from 'fs-extra/esm'
|
||||||
|
import { basename, join } from 'path'
|
||||||
import { ConfigManager } from '../../../shared/config-manager.js'
|
import { ConfigManager } from '../../../shared/config-manager.js'
|
||||||
import { logger } from '../../../shared/index.js'
|
import { logger } from '../../../shared/index.js'
|
||||||
import { buildFFmpegLive, ProcessOptions } from './common.js'
|
import { buildFFmpegLive, ProcessOptions } from './common.js'
|
||||||
|
@ -51,6 +57,7 @@ export class ProcessLiveRTMPHLSTranscoding {
|
||||||
logger.info({ probe }, `Probed ${payload.input.rtmpUrl}`)
|
logger.info({ probe }, `Probed ${payload.input.rtmpUrl}`)
|
||||||
|
|
||||||
const hasAudio = await hasAudioStream(payload.input.rtmpUrl, probe)
|
const hasAudio = await hasAudioStream(payload.input.rtmpUrl, probe)
|
||||||
|
const hasVideo = await hasVideoStream(payload.input.rtmpUrl, probe)
|
||||||
const bitrate = await getVideoStreamBitrate(payload.input.rtmpUrl, probe)
|
const bitrate = await getVideoStreamBitrate(payload.input.rtmpUrl, probe)
|
||||||
const { ratio } = await getVideoStreamDimensionsInfo(payload.input.rtmpUrl, probe)
|
const { ratio } = await getVideoStreamDimensionsInfo(payload.input.rtmpUrl, probe)
|
||||||
|
|
||||||
|
@ -103,11 +110,13 @@ export class ProcessLiveRTMPHLSTranscoding {
|
||||||
segmentDuration: payload.output.segmentDuration,
|
segmentDuration: payload.output.segmentDuration,
|
||||||
|
|
||||||
toTranscode: payload.output.toTranscode,
|
toTranscode: payload.output.toTranscode,
|
||||||
|
splitAudioAndVideo: true,
|
||||||
|
|
||||||
bitrate,
|
bitrate,
|
||||||
ratio,
|
ratio,
|
||||||
|
|
||||||
hasAudio,
|
hasAudio,
|
||||||
|
hasVideo,
|
||||||
probe
|
probe
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { remove } from 'fs-extra/esm'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { pick } from '@peertube/peertube-core-utils'
|
import { pick } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import {
|
||||||
RunnerJobStudioTranscodingPayload,
|
RunnerJobStudioTranscodingPayload,
|
||||||
|
@ -12,17 +10,30 @@ import {
|
||||||
VideoStudioTranscodingSuccess
|
VideoStudioTranscodingSuccess
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||||
|
import { remove } from 'fs-extra/esm'
|
||||||
|
import { join } from 'path'
|
||||||
import { ConfigManager } from '../../../shared/config-manager.js'
|
import { ConfigManager } from '../../../shared/config-manager.js'
|
||||||
import { logger } from '../../../shared/index.js'
|
import { logger } from '../../../shared/index.js'
|
||||||
import { buildFFmpegEdition, downloadInputFile, JobWithToken, ProcessOptions, scheduleTranscodingProgress } from './common.js'
|
import {
|
||||||
|
buildFFmpegEdition,
|
||||||
|
downloadInputFile,
|
||||||
|
downloadSeparatedAudioFileIfNeeded,
|
||||||
|
JobWithToken,
|
||||||
|
ProcessOptions,
|
||||||
|
scheduleTranscodingProgress
|
||||||
|
} from './common.js'
|
||||||
|
|
||||||
export async function processStudioTranscoding (options: ProcessOptions<RunnerJobStudioTranscodingPayload>) {
|
export async function processStudioTranscoding (options: ProcessOptions<RunnerJobStudioTranscodingPayload>) {
|
||||||
const { server, job, runnerToken } = options
|
const { server, job, runnerToken } = options
|
||||||
const payload = job.payload
|
const payload = job.payload
|
||||||
|
|
||||||
let inputPath: string
|
let videoInputPath: string
|
||||||
|
let separatedAudioInputPath: string
|
||||||
|
|
||||||
|
let tmpVideoInputFilePath: string
|
||||||
|
let tmpSeparatedAudioInputFilePath: string
|
||||||
|
|
||||||
let outputPath: string
|
let outputPath: string
|
||||||
let tmpInputFilePath: string
|
|
||||||
|
|
||||||
let tasksProgress = 0
|
let tasksProgress = 0
|
||||||
|
|
||||||
|
@ -36,8 +47,11 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
|
||||||
try {
|
try {
|
||||||
logger.info(`Downloading input file ${payload.input.videoFileUrl} for job ${job.jobToken}`)
|
logger.info(`Downloading input file ${payload.input.videoFileUrl} for job ${job.jobToken}`)
|
||||||
|
|
||||||
inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
|
videoInputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
|
||||||
tmpInputFilePath = inputPath
|
separatedAudioInputPath = await downloadSeparatedAudioFileIfNeeded({ urls: payload.input.separatedAudioFileUrl, runnerToken, job })
|
||||||
|
|
||||||
|
tmpVideoInputFilePath = videoInputPath
|
||||||
|
tmpSeparatedAudioInputFilePath = separatedAudioInputPath
|
||||||
|
|
||||||
logger.info(`Input file ${payload.input.videoFileUrl} downloaded for job ${job.jobToken}. Running studio transcoding tasks.`)
|
logger.info(`Input file ${payload.input.videoFileUrl} downloaded for job ${job.jobToken}. Running studio transcoding tasks.`)
|
||||||
|
|
||||||
|
@ -46,17 +60,20 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
|
||||||
outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename)
|
outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename)
|
||||||
|
|
||||||
await processTask({
|
await processTask({
|
||||||
inputPath: tmpInputFilePath,
|
videoInputPath: tmpVideoInputFilePath,
|
||||||
|
separatedAudioInputPath: tmpSeparatedAudioInputFilePath,
|
||||||
outputPath,
|
outputPath,
|
||||||
task,
|
task,
|
||||||
job,
|
job,
|
||||||
runnerToken
|
runnerToken
|
||||||
})
|
})
|
||||||
|
|
||||||
if (tmpInputFilePath) await remove(tmpInputFilePath)
|
if (tmpVideoInputFilePath) await remove(tmpVideoInputFilePath)
|
||||||
|
if (tmpSeparatedAudioInputFilePath) await remove(tmpSeparatedAudioInputFilePath)
|
||||||
|
|
||||||
// For the next iteration
|
// For the next iteration
|
||||||
tmpInputFilePath = outputPath
|
tmpVideoInputFilePath = outputPath
|
||||||
|
tmpSeparatedAudioInputFilePath = undefined
|
||||||
|
|
||||||
tasksProgress += Math.floor(100 / payload.tasks.length)
|
tasksProgress += Math.floor(100 / payload.tasks.length)
|
||||||
}
|
}
|
||||||
|
@ -72,7 +89,8 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
|
||||||
payload: successBody
|
payload: successBody
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (tmpInputFilePath) await remove(tmpInputFilePath)
|
if (tmpVideoInputFilePath) await remove(tmpVideoInputFilePath)
|
||||||
|
if (tmpSeparatedAudioInputFilePath) await remove(tmpSeparatedAudioInputFilePath)
|
||||||
if (outputPath) await remove(outputPath)
|
if (outputPath) await remove(outputPath)
|
||||||
if (updateProgressInterval) clearInterval(updateProgressInterval)
|
if (updateProgressInterval) clearInterval(updateProgressInterval)
|
||||||
}
|
}
|
||||||
|
@ -83,8 +101,11 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
|
type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
|
||||||
inputPath: string
|
videoInputPath: string
|
||||||
|
separatedAudioInputPath: string
|
||||||
|
|
||||||
outputPath: string
|
outputPath: string
|
||||||
|
|
||||||
task: T
|
task: T
|
||||||
runnerToken: string
|
runnerToken: string
|
||||||
job: JobWithToken
|
job: JobWithToken
|
||||||
|
@ -107,15 +128,15 @@ async function processTask (options: TaskProcessorOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) {
|
async function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) {
|
||||||
const { inputPath, task, runnerToken, job } = options
|
const { videoInputPath, task, runnerToken, job } = options
|
||||||
|
|
||||||
logger.debug('Adding intro/outro to ' + inputPath)
|
logger.debug(`Adding intro/outro to ${videoInputPath}`)
|
||||||
|
|
||||||
const introOutroPath = await downloadInputFile({ url: task.options.file, runnerToken, job })
|
const introOutroPath = await downloadInputFile({ url: task.options.file, runnerToken, job })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await buildFFmpegEdition().addIntroOutro({
|
await buildFFmpegEdition().addIntroOutro({
|
||||||
...pick(options, [ 'inputPath', 'outputPath' ]),
|
...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
|
||||||
|
|
||||||
introOutroPath,
|
introOutroPath,
|
||||||
type: task.name === 'add-intro'
|
type: task.name === 'add-intro'
|
||||||
|
@ -128,12 +149,12 @@ async function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTa
|
||||||
}
|
}
|
||||||
|
|
||||||
function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
|
function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
|
||||||
const { inputPath, task } = options
|
const { videoInputPath, task } = options
|
||||||
|
|
||||||
logger.debug(`Cutting ${inputPath}`)
|
logger.debug(`Cutting ${videoInputPath}`)
|
||||||
|
|
||||||
return buildFFmpegEdition().cutVideo({
|
return buildFFmpegEdition().cutVideo({
|
||||||
...pick(options, [ 'inputPath', 'outputPath' ]),
|
...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
|
||||||
|
|
||||||
start: task.options.start,
|
start: task.options.start,
|
||||||
end: task.options.end
|
end: task.options.end
|
||||||
|
@ -141,15 +162,15 @@ function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) {
|
async function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) {
|
||||||
const { inputPath, task, runnerToken, job } = options
|
const { videoInputPath, task, runnerToken, job } = options
|
||||||
|
|
||||||
logger.debug('Adding watermark to ' + inputPath)
|
logger.debug(`Adding watermark to ${videoInputPath}`)
|
||||||
|
|
||||||
const watermarkPath = await downloadInputFile({ url: task.options.file, runnerToken, job })
|
const watermarkPath = await downloadInputFile({ url: task.options.file, runnerToken, job })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await buildFFmpegEdition().addWatermark({
|
await buildFFmpegEdition().addWatermark({
|
||||||
...pick(options, [ 'inputPath', 'outputPath' ]),
|
...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
|
||||||
|
|
||||||
watermarkPath,
|
watermarkPath,
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { remove } from 'fs-extra/esm'
|
|
||||||
import { join } from 'path'
|
|
||||||
import {
|
import {
|
||||||
RunnerJobVODAudioMergeTranscodingPayload,
|
RunnerJobVODAudioMergeTranscodingPayload,
|
||||||
RunnerJobVODHLSTranscodingPayload,
|
RunnerJobVODHLSTranscodingPayload,
|
||||||
|
@ -9,9 +7,17 @@ import {
|
||||||
VODWebVideoTranscodingSuccess
|
VODWebVideoTranscodingSuccess
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||||
|
import { remove } from 'fs-extra/esm'
|
||||||
|
import { join } from 'path'
|
||||||
import { ConfigManager } from '../../../shared/config-manager.js'
|
import { ConfigManager } from '../../../shared/config-manager.js'
|
||||||
import { logger } from '../../../shared/index.js'
|
import { logger } from '../../../shared/index.js'
|
||||||
import { buildFFmpegVOD, downloadInputFile, ProcessOptions, scheduleTranscodingProgress } from './common.js'
|
import {
|
||||||
|
buildFFmpegVOD,
|
||||||
|
downloadInputFile,
|
||||||
|
downloadSeparatedAudioFileIfNeeded,
|
||||||
|
ProcessOptions,
|
||||||
|
scheduleTranscodingProgress
|
||||||
|
} from './common.js'
|
||||||
|
|
||||||
export async function processWebVideoTranscoding (options: ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>) {
|
export async function processWebVideoTranscoding (options: ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>) {
|
||||||
const { server, job, runnerToken } = options
|
const { server, job, runnerToken } = options
|
||||||
|
@ -19,7 +25,8 @@ export async function processWebVideoTranscoding (options: ProcessOptions<Runner
|
||||||
const payload = job.payload
|
const payload = job.payload
|
||||||
|
|
||||||
let ffmpegProgress: number
|
let ffmpegProgress: number
|
||||||
let inputPath: string
|
let videoInputPath: string
|
||||||
|
let separatedAudioInputPath: string
|
||||||
|
|
||||||
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
|
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
|
||||||
|
|
||||||
|
@ -33,7 +40,8 @@ export async function processWebVideoTranscoding (options: ProcessOptions<Runner
|
||||||
try {
|
try {
|
||||||
logger.info(`Downloading input file ${payload.input.videoFileUrl} for web video transcoding job ${job.jobToken}`)
|
logger.info(`Downloading input file ${payload.input.videoFileUrl} for web video transcoding job ${job.jobToken}`)
|
||||||
|
|
||||||
inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
|
videoInputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
|
||||||
|
separatedAudioInputPath = await downloadSeparatedAudioFileIfNeeded({ urls: payload.input.separatedAudioFileUrl, runnerToken, job })
|
||||||
|
|
||||||
logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running web video transcoding.`)
|
logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running web video transcoding.`)
|
||||||
|
|
||||||
|
@ -44,7 +52,8 @@ export async function processWebVideoTranscoding (options: ProcessOptions<Runner
|
||||||
await ffmpegVod.transcode({
|
await ffmpegVod.transcode({
|
||||||
type: 'video',
|
type: 'video',
|
||||||
|
|
||||||
inputPath,
|
videoInputPath,
|
||||||
|
separatedAudioInputPath,
|
||||||
|
|
||||||
outputPath,
|
outputPath,
|
||||||
|
|
||||||
|
@ -65,7 +74,8 @@ export async function processWebVideoTranscoding (options: ProcessOptions<Runner
|
||||||
payload: successBody
|
payload: successBody
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (inputPath) await remove(inputPath)
|
if (videoInputPath) await remove(videoInputPath)
|
||||||
|
if (separatedAudioInputPath) await remove(separatedAudioInputPath)
|
||||||
if (outputPath) await remove(outputPath)
|
if (outputPath) await remove(outputPath)
|
||||||
if (updateProgressInterval) clearInterval(updateProgressInterval)
|
if (updateProgressInterval) clearInterval(updateProgressInterval)
|
||||||
}
|
}
|
||||||
|
@ -76,7 +86,8 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
|
||||||
const payload = job.payload
|
const payload = job.payload
|
||||||
|
|
||||||
let ffmpegProgress: number
|
let ffmpegProgress: number
|
||||||
let inputPath: string
|
let videoInputPath: string
|
||||||
|
let separatedAudioInputPath: string
|
||||||
|
|
||||||
const uuid = buildUUID()
|
const uuid = buildUUID()
|
||||||
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `${uuid}-${payload.output.resolution}.m3u8`)
|
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `${uuid}-${payload.output.resolution}.m3u8`)
|
||||||
|
@ -93,7 +104,8 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
|
||||||
try {
|
try {
|
||||||
logger.info(`Downloading input file ${payload.input.videoFileUrl} for HLS transcoding job ${job.jobToken}`)
|
logger.info(`Downloading input file ${payload.input.videoFileUrl} for HLS transcoding job ${job.jobToken}`)
|
||||||
|
|
||||||
inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
|
videoInputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
|
||||||
|
separatedAudioInputPath = await downloadSeparatedAudioFileIfNeeded({ urls: payload.input.separatedAudioFileUrl, runnerToken, job })
|
||||||
|
|
||||||
logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running HLS transcoding.`)
|
logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running HLS transcoding.`)
|
||||||
|
|
||||||
|
@ -104,14 +116,18 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
|
||||||
await ffmpegVod.transcode({
|
await ffmpegVod.transcode({
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
copyCodecs: false,
|
copyCodecs: false,
|
||||||
inputPath,
|
|
||||||
|
videoInputPath,
|
||||||
|
separatedAudioInputPath,
|
||||||
|
|
||||||
hlsPlaylist: { videoFilename },
|
hlsPlaylist: { videoFilename },
|
||||||
outputPath,
|
outputPath,
|
||||||
|
|
||||||
inputFileMutexReleaser: () => {},
|
inputFileMutexReleaser: () => {},
|
||||||
|
|
||||||
resolution: payload.output.resolution,
|
resolution: payload.output.resolution,
|
||||||
fps: payload.output.fps
|
fps: payload.output.fps,
|
||||||
|
separatedAudio: payload.output.separatedAudio
|
||||||
})
|
})
|
||||||
|
|
||||||
const successBody: VODHLSTranscodingSuccess = {
|
const successBody: VODHLSTranscodingSuccess = {
|
||||||
|
@ -126,7 +142,8 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
|
||||||
payload: successBody
|
payload: successBody
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (inputPath) await remove(inputPath)
|
if (videoInputPath) await remove(videoInputPath)
|
||||||
|
if (separatedAudioInputPath) await remove(separatedAudioInputPath)
|
||||||
if (outputPath) await remove(outputPath)
|
if (outputPath) await remove(outputPath)
|
||||||
if (videoPath) await remove(videoPath)
|
if (videoPath) await remove(videoPath)
|
||||||
if (updateProgressInterval) clearInterval(updateProgressInterval)
|
if (updateProgressInterval) clearInterval(updateProgressInterval)
|
||||||
|
@ -139,7 +156,7 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
|
||||||
|
|
||||||
let ffmpegProgress: number
|
let ffmpegProgress: number
|
||||||
let audioPath: string
|
let audioPath: string
|
||||||
let inputPath: string
|
let previewPath: string
|
||||||
|
|
||||||
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
|
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
|
||||||
|
|
||||||
|
@ -157,7 +174,7 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
|
||||||
)
|
)
|
||||||
|
|
||||||
audioPath = await downloadInputFile({ url: payload.input.audioFileUrl, runnerToken, job })
|
audioPath = await downloadInputFile({ url: payload.input.audioFileUrl, runnerToken, job })
|
||||||
inputPath = await downloadInputFile({ url: payload.input.previewFileUrl, runnerToken, job })
|
previewPath = await downloadInputFile({ url: payload.input.previewFileUrl, runnerToken, job })
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Downloaded input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` +
|
`Downloaded input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` +
|
||||||
|
@ -172,7 +189,7 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
|
||||||
type: 'merge-audio',
|
type: 'merge-audio',
|
||||||
|
|
||||||
audioPath,
|
audioPath,
|
||||||
inputPath,
|
videoInputPath: previewPath,
|
||||||
|
|
||||||
outputPath,
|
outputPath,
|
||||||
|
|
||||||
|
@ -194,7 +211,7 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (audioPath) await remove(audioPath)
|
if (audioPath) await remove(audioPath)
|
||||||
if (inputPath) await remove(inputPath)
|
if (previewPath) await remove(previewPath)
|
||||||
if (outputPath) await remove(outputPath)
|
if (outputPath) await remove(outputPath)
|
||||||
if (updateProgressInterval) clearInterval(updateProgressInterval)
|
if (updateProgressInterval) clearInterval(updateProgressInterval)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,12 +11,13 @@ export type ResolutionOption = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EditConfigurationService {
|
export class EditConfigurationService {
|
||||||
|
|
||||||
getVODResolutions () {
|
getTranscodingResolutions () {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: '0p',
|
id: '0p',
|
||||||
label: $localize`Audio-only`,
|
label: $localize`Audio-only`,
|
||||||
description: $localize`A <code>.mp4</code> that keeps the original audio track, with no video`
|
// eslint-disable-next-line max-len
|
||||||
|
description: $localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '144p',
|
id: '144p',
|
||||||
|
@ -53,14 +54,14 @@ export class EditConfigurationService {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
getLiveResolutions () {
|
|
||||||
return this.getVODResolutions().filter(r => r.id !== '0p')
|
|
||||||
}
|
|
||||||
|
|
||||||
isTranscodingEnabled (form: FormGroup) {
|
isTranscodingEnabled (form: FormGroup) {
|
||||||
return form.value['transcoding']['enabled'] === true
|
return form.value['transcoding']['enabled'] === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isHLSEnabled (form: FormGroup) {
|
||||||
|
return form.value['transcoding']['hls']['enabled'] === true
|
||||||
|
}
|
||||||
|
|
||||||
isRemoteRunnerVODEnabled (form: FormGroup) {
|
isRemoteRunnerVODEnabled (form: FormGroup) {
|
||||||
return form.value['transcoding']['remoteRunners']['enabled'] === true
|
return form.value['transcoding']['remoteRunners']['enabled'] === true
|
||||||
}
|
}
|
||||||
|
|
|
@ -152,3 +152,8 @@ my-actor-banner-edit {
|
||||||
max-width: $form-max-width;
|
max-width: $form-max-width;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: $font-bold;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import omit from 'lodash-es/omit'
|
import { NgFor, NgIf } from '@angular/common'
|
||||||
import { forkJoin } from 'rxjs'
|
|
||||||
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
|
||||||
import { Component, OnInit } from '@angular/core'
|
import { Component, OnInit } from '@angular/core'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { ConfigService } from '@app/+admin/config/shared/config.service'
|
import { ConfigService } from '@app/+admin/config/shared/config.service'
|
||||||
import { Notifier } from '@app/core'
|
import { Notifier } from '@app/core'
|
||||||
|
@ -28,18 +27,19 @@ import {
|
||||||
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
|
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
|
||||||
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
||||||
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
||||||
|
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
|
||||||
|
import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models'
|
import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models'
|
||||||
import { EditConfigurationService } from './edit-configuration.service'
|
import omit from 'lodash-es/omit'
|
||||||
|
import { forkJoin } from 'rxjs'
|
||||||
|
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||||
import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component'
|
import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component'
|
||||||
|
import { EditBasicConfigurationComponent } from './edit-basic-configuration.component'
|
||||||
|
import { EditConfigurationService } from './edit-configuration.service'
|
||||||
|
import { EditHomepageComponent } from './edit-homepage.component'
|
||||||
|
import { EditInstanceInformationComponent } from './edit-instance-information.component'
|
||||||
import { EditLiveConfigurationComponent } from './edit-live-configuration.component'
|
import { EditLiveConfigurationComponent } from './edit-live-configuration.component'
|
||||||
import { EditVODTranscodingComponent } from './edit-vod-transcoding.component'
|
import { EditVODTranscodingComponent } from './edit-vod-transcoding.component'
|
||||||
import { EditBasicConfigurationComponent } from './edit-basic-configuration.component'
|
|
||||||
import { EditInstanceInformationComponent } from './edit-instance-information.component'
|
|
||||||
import { EditHomepageComponent } from './edit-homepage.component'
|
|
||||||
import { NgbNav, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|
||||||
import { NgIf, NgFor } from '@angular/common'
|
|
||||||
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
|
|
||||||
|
|
||||||
type ComponentCustomConfig = CustomConfig & {
|
type ComponentCustomConfig = CustomConfig & {
|
||||||
instanceCustomHomepage: CustomPage
|
instanceCustomHomepage: CustomPage
|
||||||
|
@ -230,7 +230,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||||
keep: null
|
keep: null
|
||||||
},
|
},
|
||||||
hls: {
|
hls: {
|
||||||
enabled: null
|
enabled: null,
|
||||||
|
splitAudioAndVideo: null
|
||||||
},
|
},
|
||||||
webVideos: {
|
webVideos: {
|
||||||
enabled: null
|
enabled: null
|
||||||
|
@ -341,12 +342,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const resolution of this.editConfigurationService.getVODResolutions()) {
|
for (const resolution of this.editConfigurationService.getTranscodingResolutions()) {
|
||||||
defaultValues.transcoding.resolutions[resolution.id] = 'false'
|
defaultValues.transcoding.resolutions[resolution.id] = 'false'
|
||||||
formGroupData.transcoding.resolutions[resolution.id] = null
|
formGroupData.transcoding.resolutions[resolution.id] = null
|
||||||
}
|
|
||||||
|
|
||||||
for (const resolution of this.editConfigurationService.getLiveResolutions()) {
|
|
||||||
defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
|
defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
|
||||||
formGroupData.live.transcoding.resolutions[resolution.id] = null
|
formGroupData.live.transcoding.resolutions[resolution.id] = null
|
||||||
}
|
}
|
||||||
|
|
|
@ -114,10 +114,12 @@
|
||||||
<div class="callout callout-light pt-2 mt-2 pb-0">
|
<div class="callout callout-light pt-2 mt-2 pb-0">
|
||||||
<h3 class="callout-title" i18n>Output formats</h3>
|
<h3 class="callout-title" i18n>Output formats</h3>
|
||||||
|
|
||||||
<div class="form-group" [ngClass]="getDisabledLiveTranscodingClass()">
|
<div [ngClass]="getDisabledLiveTranscodingClass()">
|
||||||
<label i18n for="liveTranscodingThreads">Live resolutions to generate</label>
|
|
||||||
|
|
||||||
<div class="ms-2 mt-2 d-flex flex-column">
|
<div class="ms-2 mt-3">
|
||||||
|
<h4 i18n>Live resolutions to generate</h4>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
|
||||||
<ng-container formGroupName="resolutions">
|
<ng-container formGroupName="resolutions">
|
||||||
<div class="form-group" *ngFor="let resolution of liveResolutions">
|
<div class="form-group" *ngFor="let resolution of liveResolutions">
|
||||||
|
@ -134,7 +136,7 @@
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<my-peertube-checkbox
|
<my-peertube-checkbox
|
||||||
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
|
inputName="liveTranscodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
|
||||||
i18n-labelText labelText="Also transcode original resolution"
|
i18n-labelText labelText="Also transcode original resolution"
|
||||||
>
|
>
|
||||||
<ng-container i18n ngProjectAs="description">
|
<ng-container i18n ngProjectAs="description">
|
||||||
|
@ -145,10 +147,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group mt-4" formGroupName="remoteRunners" [ngClass]="getDisabledLiveTranscodingClass()">
|
<div class="form-group mt-4" formGroupName="remoteRunners" [ngClass]="getDisabledLiveTranscodingClass()">
|
||||||
<my-peertube-checkbox
|
<my-peertube-checkbox
|
||||||
inputName="transcodingRemoteRunnersEnabled" formControlName="enabled"
|
inputName="liveTranscodingRemoteRunnersEnabled" formControlName="enabled"
|
||||||
i18n-labelText labelText="Enable remote runners for lives"
|
i18n-labelText labelText="Enable remote runners for lives"
|
||||||
>
|
>
|
||||||
<ng-container ngProjectAs="description">
|
<ng-container ngProjectAs="description">
|
||||||
|
|
|
@ -56,7 +56,7 @@ export class EditLiveConfigurationComponent implements OnInit, OnChanges {
|
||||||
{ id: 1000 * 3600 * 10, label: $localize`10 hours` }
|
{ id: 1000 * 3600 * 10, label: $localize`10 hours` }
|
||||||
]
|
]
|
||||||
|
|
||||||
this.liveResolutions = this.editConfigurationService.getLiveResolutions()
|
this.liveResolutions = this.editConfigurationService.getTranscodingResolutions()
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges (changes: SimpleChanges) {
|
ngOnChanges (changes: SimpleChanges) {
|
||||||
|
|
|
@ -115,24 +115,32 @@
|
||||||
<p>If you also enabled Web Videos support, it will multiply videos storage by 2</p>
|
<p>If you also enabled Web Videos support, it will multiply videos storage by 2</p>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-container ngProjectAs="extra">
|
||||||
|
|
||||||
|
<div class="form-group" [ngClass]="getHLSDisabledClass()">
|
||||||
|
<my-peertube-checkbox
|
||||||
|
inputName="transcodingHlsSplitAudioAndVideo" formControlName="splitAudioAndVideo"
|
||||||
|
i18n-labelText labelText="Split audio and video streams"
|
||||||
|
>
|
||||||
|
<ng-template ptTemplate="help">
|
||||||
|
<ng-container i18n>Store the audio stream in a separate file from the video.</ng-container> <br />
|
||||||
|
<ng-container i18n>This option adds the ability for the HLS player to propose the "Audio only" quality to users.</ng-container> <br />
|
||||||
|
<ng-container i18n>It also saves disk space by not duplicating the audio stream in each resolution file</ng-container>
|
||||||
|
</ng-template>
|
||||||
</my-peertube-checkbox>
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</ng-container>
|
||||||
|
</my-peertube-checkbox>
|
||||||
|
|
||||||
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
|
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
|
||||||
<div class="mb-2 fw-bold" i18n>Resolutions to generate</div>
|
<div class="mb-2 fw-bold" i18n>Resolutions to generate</div>
|
||||||
|
|
||||||
<div class="ms-2 d-flex flex-column">
|
<div class="ms-2 d-flex flex-column">
|
||||||
<my-peertube-checkbox
|
|
||||||
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
|
|
||||||
i18n-labelText labelText="Always transcode original resolution"
|
|
||||||
>
|
|
||||||
</my-peertube-checkbox>
|
|
||||||
|
|
||||||
<span class="mt-3 mb-2 small muted" i18n>
|
|
||||||
The original file resolution will be the default target if no option is selected.
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<ng-container formGroupName="resolutions">
|
<ng-container formGroupName="resolutions">
|
||||||
<div class="form-group" *ngFor="let resolution of resolutions">
|
<div class="form-group" *ngFor="let resolution of resolutions">
|
||||||
<my-peertube-checkbox
|
<my-peertube-checkbox
|
||||||
|
@ -145,6 +153,15 @@
|
||||||
</my-peertube-checkbox>
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<my-peertube-checkbox
|
||||||
|
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
|
||||||
|
i18n-labelText labelText="Also transcode original resolution"
|
||||||
|
>
|
||||||
|
<ng-container i18n ngProjectAs="description">
|
||||||
|
Even if it's above your maximum enabled resolution
|
||||||
|
</ng-container>
|
||||||
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
import { NgClass, NgFor, NgIf } from '@angular/common'
|
||||||
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
|
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
|
||||||
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { RouterLink } from '@angular/router'
|
||||||
|
import { Notifier } from '@app/core'
|
||||||
import { HTMLServerConfig } from '@peertube/peertube-models'
|
import { HTMLServerConfig } from '@peertube/peertube-models'
|
||||||
|
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||||
|
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
|
||||||
|
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
|
||||||
|
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
|
||||||
|
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
|
||||||
import { ConfigService } from '../shared/config.service'
|
import { ConfigService } from '../shared/config.service'
|
||||||
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
|
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
|
||||||
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
|
|
||||||
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
|
|
||||||
import { RouterLink } from '@angular/router'
|
|
||||||
import { NgClass, NgFor, NgIf } from '@angular/common'
|
|
||||||
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
|
|
||||||
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-edit-vod-transcoding',
|
selector: 'my-edit-vod-transcoding',
|
||||||
|
@ -42,12 +43,13 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private editConfigurationService: EditConfigurationService
|
private editConfigurationService: EditConfigurationService,
|
||||||
|
private notifier: Notifier
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
|
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
|
||||||
this.resolutions = this.editConfigurationService.getVODResolutions()
|
this.resolutions = this.editConfigurationService.getTranscodingResolutions()
|
||||||
|
|
||||||
this.checkTranscodingFields()
|
this.checkTranscodingFields()
|
||||||
}
|
}
|
||||||
|
@ -84,6 +86,10 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
|
||||||
return this.editConfigurationService.isTranscodingEnabled(this.form)
|
return this.editConfigurationService.isTranscodingEnabled(this.form)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isHLSEnabled () {
|
||||||
|
return this.editConfigurationService.isHLSEnabled(this.form)
|
||||||
|
}
|
||||||
|
|
||||||
isStudioEnabled () {
|
isStudioEnabled () {
|
||||||
return this.editConfigurationService.isStudioEnabled(this.form)
|
return this.editConfigurationService.isStudioEnabled(this.form)
|
||||||
}
|
}
|
||||||
|
@ -92,6 +98,10 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
|
||||||
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
|
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getHLSDisabledClass () {
|
||||||
|
return { 'disabled-checkbox-extra': !this.isHLSEnabled() }
|
||||||
|
}
|
||||||
|
|
||||||
getLocalTranscodingDisabledClass () {
|
getLocalTranscodingDisabledClass () {
|
||||||
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
|
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
|
||||||
}
|
}
|
||||||
|
@ -112,23 +122,21 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
|
||||||
|
|
||||||
webVideosControl.valueChanges
|
webVideosControl.valueChanges
|
||||||
.subscribe(newValue => {
|
.subscribe(newValue => {
|
||||||
if (newValue === false && !hlsControl.disabled) {
|
if (newValue === false && hlsControl.value === false) {
|
||||||
hlsControl.disable()
|
hlsControl.setValue(true)
|
||||||
}
|
|
||||||
|
|
||||||
if (newValue === true && !hlsControl.enabled) {
|
// eslint-disable-next-line max-len
|
||||||
hlsControl.enable()
|
this.notifier.info($localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
hlsControl.valueChanges
|
hlsControl.valueChanges
|
||||||
.subscribe(newValue => {
|
.subscribe(newValue => {
|
||||||
if (newValue === false && !webVideosControl.disabled) {
|
if (newValue === false && webVideosControl.value === false) {
|
||||||
webVideosControl.disable()
|
webVideosControl.setValue(true)
|
||||||
}
|
|
||||||
|
|
||||||
if (newValue === true && !webVideosControl.enabled) {
|
// eslint-disable-next-line max-len
|
||||||
webVideosControl.enable()
|
this.notifier.info($localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { VideoRateComponent } from './video-rate.component'
|
||||||
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
|
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
|
||||||
import { VideoShareComponent } from '@app/shared/shared-share-modal/video-share.component'
|
import { VideoShareComponent } from '@app/shared/shared-share-modal/video-share.component'
|
||||||
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
|
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
|
||||||
import { VideoDownloadComponent } from '@app/shared/shared-video-miniature/video-download.component'
|
import { VideoDownloadComponent } from '@app/shared/shared-video-miniature/download/video-download.component'
|
||||||
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
|
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
|
@ -65,13 +65,4 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
|
||||||
hasHlsPlaylist () {
|
hasHlsPlaylist () {
|
||||||
return !!this.getHlsPlaylist()
|
return !!this.getHlsPlaylist()
|
||||||
}
|
}
|
||||||
|
|
||||||
getFiles () {
|
|
||||||
if (this.files.length !== 0) return this.files
|
|
||||||
|
|
||||||
const hls = this.getHlsPlaylist()
|
|
||||||
if (hls) return hls.files
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
VideoChannel as VideoChannelServerModel,
|
VideoChannel as VideoChannelServerModel,
|
||||||
VideoConstant,
|
VideoConstant,
|
||||||
VideoDetails as VideoDetailsServerModel,
|
VideoDetails as VideoDetailsServerModel,
|
||||||
|
VideoFile,
|
||||||
VideoFileMetadata,
|
VideoFileMetadata,
|
||||||
VideoIncludeType,
|
VideoIncludeType,
|
||||||
VideoPrivacy,
|
VideoPrivacy,
|
||||||
|
@ -54,6 +55,7 @@ export type CommonVideoParams = {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VideoService {
|
export class VideoService {
|
||||||
|
static BASE_VIDEO_DOWNLOAD_URL = environment.originServerUrl + '/download/videos/generate'
|
||||||
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
|
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
|
||||||
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
|
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
|
||||||
static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml'
|
static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml'
|
||||||
|
@ -388,6 +390,22 @@ export class VideoService {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
generateDownloadUrl (options: {
|
||||||
|
video: Video
|
||||||
|
files: VideoFile[]
|
||||||
|
}) {
|
||||||
|
const { video, files } = options
|
||||||
|
|
||||||
|
if (files.length === 0) throw new Error('Cannot generate download URL without files')
|
||||||
|
|
||||||
|
let url = `${VideoService.BASE_VIDEO_DOWNLOAD_URL}/${video.uuid}?`
|
||||||
|
url += files.map(f => 'videoFileIds=' + f.id).join('&')
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
getStoryboards (videoId: string | number, videoPassword: string) {
|
getStoryboards (videoId: string | number, videoPassword: string) {
|
||||||
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
|
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
|
||||||
|
|
||||||
|
|
|
@ -179,17 +179,17 @@ export class UserSubscriptionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
doesSubscriptionExist (nameWithHost: string) {
|
doesSubscriptionExist (nameWithHost: string) {
|
||||||
debugLogger('Running subscription check for %d.', nameWithHost)
|
debugLogger('Running subscription check for ' + nameWithHost)
|
||||||
|
|
||||||
if (nameWithHost in this.myAccountSubscriptionCache) {
|
if (nameWithHost in this.myAccountSubscriptionCache) {
|
||||||
debugLogger('Found cache for %d.', nameWithHost)
|
debugLogger('Found cache for ' + nameWithHost)
|
||||||
|
|
||||||
return of(this.myAccountSubscriptionCache[nameWithHost])
|
return of(this.myAccountSubscriptionCache[nameWithHost])
|
||||||
}
|
}
|
||||||
|
|
||||||
this.existsSubject.next(nameWithHost)
|
this.existsSubject.next(nameWithHost)
|
||||||
|
|
||||||
debugLogger('Fetching from network for %d.', nameWithHost)
|
debugLogger('Fetching from network for ' + nameWithHost)
|
||||||
return this.existsObservable.pipe(
|
return this.existsObservable.pipe(
|
||||||
filter(existsResult => existsResult[nameWithHost] !== undefined),
|
filter(existsResult => existsResult[nameWithHost] !== undefined),
|
||||||
map(existsResult => existsResult[nameWithHost]),
|
map(existsResult => existsResult[nameWithHost]),
|
||||||
|
|
|
@ -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 '_variables' as *;
|
||||||
@use '_mixins' as *;
|
@use '_mixins' as *;
|
||||||
|
|
||||||
.nav-content {
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
my-global-icon[iconName=shield] {
|
|
||||||
@include margin-left(10px);
|
|
||||||
|
|
||||||
width: 16px;
|
|
||||||
margin-top: -3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advanced-filters-button {
|
.advanced-filters-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
@ -25,28 +14,6 @@ my-global-icon[iconName=shield] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.peertube-select-container.title-select {
|
|
||||||
@include peertube-select-container(auto);
|
|
||||||
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 10px;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
#dropdown-download-type {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-type {
|
|
||||||
margin-top: 20px;
|
|
||||||
|
|
||||||
.peertube-radio-container {
|
|
||||||
@include margin-right(30px);
|
|
||||||
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-metadata {
|
.nav-metadata {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
@ -69,3 +36,13 @@ my-global-icon[iconName=shield] {
|
||||||
font-weight: $font-bold;
|
font-weight: $font-bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-type {
|
||||||
|
margin-top: 20px;
|
||||||
|
|
||||||
|
.peertube-radio-container {
|
||||||
|
@include margin-right(30px);
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,8 @@
|
||||||
import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
|
import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core'
|
import { Component, EventEmitter, Inject, Input, LOCALE_ID, OnInit, Output } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { AuthService, HooksService } from '@app/core'
|
|
||||||
import {
|
import {
|
||||||
NgbCollapse,
|
NgbCollapse,
|
||||||
NgbModal,
|
|
||||||
NgbModalRef,
|
|
||||||
NgbNav,
|
NgbNav,
|
||||||
NgbNavContent,
|
NgbNavContent,
|
||||||
NgbNavItem,
|
NgbNavItem,
|
||||||
|
@ -15,34 +12,32 @@ import {
|
||||||
NgbTooltip
|
NgbTooltip
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { objectKeysTyped, pick } from '@peertube/peertube-core-utils'
|
import { objectKeysTyped, pick } from '@peertube/peertube-core-utils'
|
||||||
import { VideoCaption, VideoFile, VideoFileMetadata, VideoSource } from '@peertube/peertube-models'
|
import { VideoFile, VideoFileMetadata, VideoSource } from '@peertube/peertube-models'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { videoRequiresFileToken } from '@root-helpers/video'
|
import { videoRequiresFileToken } from '@root-helpers/video'
|
||||||
import { mapValues } from 'lodash-es'
|
import { mapValues } from 'lodash-es'
|
||||||
import { firstValueFrom, of } from 'rxjs'
|
import { firstValueFrom } from 'rxjs'
|
||||||
import { catchError, tap } from 'rxjs/operators'
|
import { tap } from 'rxjs/operators'
|
||||||
import { InputTextComponent } from '../shared-forms/input-text.component'
|
import { InputTextComponent } from '../../shared-forms/input-text.component'
|
||||||
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
import { GlobalIconComponent } from '../../shared-icons/global-icon.component'
|
||||||
import { BytesPipe } from '../shared-main/angular/bytes.pipe'
|
import { BytesPipe } from '../../shared-main/angular/bytes.pipe'
|
||||||
import { NumberFormatterPipe } from '../shared-main/angular/number-formatter.pipe'
|
import { NumberFormatterPipe } from '../../shared-main/angular/number-formatter.pipe'
|
||||||
import { VideoDetails } from '../shared-main/video/video-details.model'
|
import { VideoDetails } from '../../shared-main/video/video-details.model'
|
||||||
import { VideoFileTokenService } from '../shared-main/video/video-file-token.service'
|
import { VideoService } from '../../shared-main/video/video.service'
|
||||||
import { VideoService } from '../shared-main/video/video.service'
|
|
||||||
|
|
||||||
type DownloadType = 'video' | 'subtitles'
|
|
||||||
type FileMetadata = { [key: string]: { label: string, value: string | number } }
|
type FileMetadata = { [key: string]: { label: string, value: string | number } }
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-video-download',
|
selector: 'my-video-files-download',
|
||||||
templateUrl: './video-download.component.html',
|
templateUrl: './video-files-download.component.html',
|
||||||
styleUrls: [ './video-download.component.scss' ],
|
styleUrls: [ './video-files-download.component.scss' ],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
NgIf,
|
NgIf,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
GlobalIconComponent,
|
GlobalIconComponent,
|
||||||
NgbNav,
|
|
||||||
NgFor,
|
NgFor,
|
||||||
|
NgbNav,
|
||||||
NgbNavItem,
|
NgbNavItem,
|
||||||
NgbNavLink,
|
NgbNavLink,
|
||||||
NgbNavLinkBase,
|
NgbNavLinkBase,
|
||||||
|
@ -56,15 +51,16 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
|
||||||
NgClass
|
NgClass
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VideoDownloadComponent {
|
export class VideoFilesDownloadComponent implements OnInit {
|
||||||
@ViewChild('modal', { static: true }) modal: ElementRef
|
@Input({ required: true }) video: VideoDetails
|
||||||
|
@Input() originalVideoFile: VideoSource
|
||||||
|
@Input() videoFileToken: string
|
||||||
|
|
||||||
@Input() videoPassword: string
|
@Output() downloaded = new EventEmitter<void>()
|
||||||
|
|
||||||
downloadType: 'direct' | 'torrent' = 'direct'
|
downloadType: 'direct' | 'torrent' = 'direct'
|
||||||
|
|
||||||
resolutionId: number | 'original' = -1
|
activeResolutionId: number | 'original' = -1
|
||||||
subtitleLanguageId: string
|
|
||||||
|
|
||||||
videoFileMetadataFormat: FileMetadata
|
videoFileMetadataFormat: FileMetadata
|
||||||
videoFileMetadataVideoStream: FileMetadata | undefined
|
videoFileMetadataVideoStream: FileMetadata | undefined
|
||||||
|
@ -72,133 +68,50 @@ export class VideoDownloadComponent {
|
||||||
|
|
||||||
isAdvancedCustomizationCollapsed = true
|
isAdvancedCustomizationCollapsed = true
|
||||||
|
|
||||||
type: DownloadType = 'video'
|
|
||||||
|
|
||||||
videoFileToken: string
|
|
||||||
|
|
||||||
originalVideoFile: VideoSource
|
|
||||||
|
|
||||||
loaded = false
|
|
||||||
|
|
||||||
private activeModal: NgbModalRef
|
|
||||||
|
|
||||||
private bytesPipe: BytesPipe
|
private bytesPipe: BytesPipe
|
||||||
private numbersPipe: NumberFormatterPipe
|
private numbersPipe: NumberFormatterPipe
|
||||||
|
|
||||||
private video: VideoDetails
|
|
||||||
private videoCaptions: VideoCaption[]
|
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
@Inject(LOCALE_ID) private localeId: string,
|
@Inject(LOCALE_ID) private localeId: string,
|
||||||
private modalService: NgbModal,
|
private videoService: VideoService
|
||||||
private authService: AuthService,
|
|
||||||
private videoService: VideoService,
|
|
||||||
private videoFileTokenService: VideoFileTokenService,
|
|
||||||
private hooks: HooksService
|
|
||||||
) {
|
) {
|
||||||
this.bytesPipe = new BytesPipe()
|
this.bytesPipe = new BytesPipe()
|
||||||
this.numbersPipe = new NumberFormatterPipe(this.localeId)
|
this.numbersPipe = new NumberFormatterPipe(this.localeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
get typeText () {
|
ngOnInit () {
|
||||||
return this.type === 'video'
|
|
||||||
? $localize`video`
|
|
||||||
: $localize`subtitles`
|
|
||||||
}
|
|
||||||
|
|
||||||
getVideoFiles () {
|
|
||||||
if (!this.video) return []
|
|
||||||
|
|
||||||
return this.video.getFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
getCaptions () {
|
|
||||||
if (!this.videoCaptions) return []
|
|
||||||
|
|
||||||
return this.videoCaptions
|
|
||||||
}
|
|
||||||
|
|
||||||
show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
|
|
||||||
this.loaded = false
|
|
||||||
|
|
||||||
this.videoFileToken = undefined
|
|
||||||
this.originalVideoFile = undefined
|
|
||||||
|
|
||||||
this.video = video
|
|
||||||
this.videoCaptions = videoCaptions
|
|
||||||
|
|
||||||
this.activeModal = this.modalService.open(this.modal, { centered: true })
|
|
||||||
|
|
||||||
if (this.hasFiles()) {
|
if (this.hasFiles()) {
|
||||||
this.onResolutionIdChange(this.getVideoFiles()[0].resolution.id)
|
this.onResolutionIdChange(this.getVideoFiles()[0].resolution.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.hasCaptions()) {
|
|
||||||
this.subtitleLanguageId = this.videoCaptions[0].language.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.getOriginalVideoFileObs()
|
getVideoFiles () {
|
||||||
.subscribe(source => {
|
if (!this.video) return []
|
||||||
if (source?.fileDownloadUrl) {
|
if (this.video.files.length !== 0) return this.video.files
|
||||||
this.originalVideoFile = source
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.originalVideoFile || this.isConfidentialVideo()) {
|
const hls = this.video.getHlsPlaylist()
|
||||||
this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
|
if (hls) return hls.files
|
||||||
.subscribe(({ token }) => {
|
|
||||||
this.videoFileToken = token
|
|
||||||
|
|
||||||
this.loaded = true
|
return []
|
||||||
})
|
|
||||||
} 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)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
onClose () {
|
|
||||||
this.video = undefined
|
|
||||||
this.videoCaptions = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
download () {
|
download () {
|
||||||
window.location.assign(this.getLink())
|
window.location.assign(this.getVideoFileLink())
|
||||||
|
|
||||||
this.activeModal.close()
|
this.downloaded.emit()
|
||||||
}
|
}
|
||||||
|
|
||||||
getLink () {
|
// ---------------------------------------------------------------------------
|
||||||
return this.type === 'subtitles' && this.videoCaptions
|
|
||||||
? this.getCaptionLink()
|
|
||||||
: this.getVideoFileLink()
|
|
||||||
}
|
|
||||||
|
|
||||||
async onResolutionIdChange (resolutionId: number | 'original') {
|
async onResolutionIdChange (resolutionId: number | 'original') {
|
||||||
this.resolutionId = resolutionId
|
this.activeResolutionId = resolutionId
|
||||||
|
|
||||||
let metadata: VideoFileMetadata
|
let metadata: VideoFileMetadata
|
||||||
|
|
||||||
if (this.resolutionId === 'original') {
|
if (this.activeResolutionId === 'original') {
|
||||||
metadata = this.originalVideoFile.metadata
|
metadata = this.originalVideoFile.metadata
|
||||||
} else {
|
} else {
|
||||||
const videoFile = this.getVideoFile()
|
const videoFile = this.getVideoFile()
|
||||||
|
@ -218,22 +131,20 @@ export class VideoDownloadComponent {
|
||||||
this.videoFileMetadataAudioStream = this.getMetadataStream(metadata.streams, 'audio')
|
this.videoFileMetadataAudioStream = this.getMetadataStream(metadata.streams, 'audio')
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubtitleIdChange (subtitleId: string) {
|
// ---------------------------------------------------------------------------
|
||||||
this.subtitleLanguageId = subtitleId
|
|
||||||
}
|
|
||||||
|
|
||||||
hasFiles () {
|
hasFiles () {
|
||||||
return this.getVideoFiles().length !== 0
|
return this.getVideoFiles().length !== 0
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideoFile () {
|
getVideoFile () {
|
||||||
if (this.resolutionId === 'original') return undefined
|
if (this.activeResolutionId === 'original') return undefined
|
||||||
|
|
||||||
const file = this.getVideoFiles()
|
const file = this.getVideoFiles()
|
||||||
.find(f => f.resolution.id === this.resolutionId)
|
.find(f => f.resolution.id === this.activeResolutionId)
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
logger.error(`Could not find file with resolution ${this.resolutionId}`)
|
logger.error(`Could not find file with resolution ${this.activeResolutionId}`)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -241,11 +152,11 @@ export class VideoDownloadComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideoFileLink () {
|
getVideoFileLink () {
|
||||||
const suffix = this.resolutionId === 'original' || this.isConfidentialVideo()
|
const suffix = this.activeResolutionId === 'original' || this.isConfidentialVideo()
|
||||||
? '?videoFileToken=' + this.videoFileToken
|
? '?videoFileToken=' + this.videoFileToken
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
if (this.resolutionId === 'original') {
|
if (this.activeResolutionId === 'original') {
|
||||||
return this.originalVideoFile.fileDownloadUrl + suffix
|
return this.originalVideoFile.fileDownloadUrl + suffix
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -261,36 +172,13 @@ export class VideoDownloadComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasCaptions () {
|
// ---------------------------------------------------------------------------
|
||||||
return this.getCaptions().length !== 0
|
|
||||||
}
|
|
||||||
|
|
||||||
getCaption () {
|
|
||||||
const caption = this.getCaptions()
|
|
||||||
.find(c => c.language.id === this.subtitleLanguageId)
|
|
||||||
|
|
||||||
if (!caption) {
|
|
||||||
logger.error(`Cannot find caption ${this.subtitleLanguageId}`)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return caption
|
|
||||||
}
|
|
||||||
|
|
||||||
getCaptionLink () {
|
|
||||||
const caption = this.getCaption()
|
|
||||||
if (!caption) return ''
|
|
||||||
|
|
||||||
return window.location.origin + caption.captionPath
|
|
||||||
}
|
|
||||||
|
|
||||||
isConfidentialVideo () {
|
isConfidentialVideo () {
|
||||||
return this.resolutionId === 'original' || videoRequiresFileToken(this.video)
|
return this.activeResolutionId === 'original' || videoRequiresFileToken(this.video)
|
||||||
}
|
}
|
||||||
|
|
||||||
switchToType (type: DownloadType) {
|
// ---------------------------------------------------------------------------
|
||||||
this.type = type
|
|
||||||
}
|
|
||||||
|
|
||||||
hasMetadata () {
|
hasMetadata () {
|
||||||
return !!this.videoFileMetadataFormat
|
return !!this.videoFileMetadataFormat
|
|
@ -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 { VideoBlockService } from '../shared-moderation/video-block.service'
|
||||||
import { LiveStreamInformationComponent } from '../shared-video-live/live-stream-information.component'
|
import { LiveStreamInformationComponent } from '../shared-video-live/live-stream-information.component'
|
||||||
import { VideoAddToPlaylistComponent } from '../shared-video-playlist/video-add-to-playlist.component'
|
import { VideoAddToPlaylistComponent } from '../shared-video-playlist/video-add-to-playlist.component'
|
||||||
import { VideoDownloadComponent } from './video-download.component'
|
import { VideoDownloadComponent } from './download/video-download.component'
|
||||||
|
|
||||||
export type VideoActionsDisplayType = {
|
export type VideoActionsDisplayType = {
|
||||||
playlist?: boolean
|
playlist?: boolean
|
||||||
|
|
|
@ -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
|
// Thanks https://github.com/streamroot/videojs-hlsjs-plugin
|
||||||
// We duplicated this plugin to choose the hls.js version we want, because streamroot only provide a bundled file
|
// We duplicated this plugin to choose the hls.js version we want, because streamroot only provide a bundled file
|
||||||
|
|
||||||
import Hlsjs, { ErrorData, HlsConfig, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
|
|
||||||
import videojs from 'video.js'
|
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types'
|
import Hlsjs, { ErrorData, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
|
||||||
|
import videojs from 'video.js'
|
||||||
|
import { HLSPluginOptions, HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types'
|
||||||
|
|
||||||
type ErrorCounts = {
|
type ErrorCounts = {
|
||||||
[ type: string ]: number
|
[ type: string ]: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metadata = {
|
|
||||||
levels: Level[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Source handler registration
|
// Source handler registration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
@ -126,10 +122,10 @@ export class Html5Hlsjs {
|
||||||
private maxNetworkErrorRecovery = 5
|
private maxNetworkErrorRecovery = 5
|
||||||
|
|
||||||
private hls: Hlsjs
|
private hls: Hlsjs
|
||||||
private hlsjsConfig: Partial<HlsConfig & { cueHandler: any }> = null
|
private hlsjsConfig: HLSPluginOptions = null
|
||||||
|
|
||||||
private _duration: number = null
|
private _duration: number = null
|
||||||
private metadata: Metadata = null
|
private metadata: ManifestParsedData = null
|
||||||
private isLive: boolean = null
|
private isLive: boolean = null
|
||||||
private dvrDuration: number = null
|
private dvrDuration: number = null
|
||||||
private edgeMargin: number = null
|
private edgeMargin: number = null
|
||||||
|
@ -139,6 +135,8 @@ export class Html5Hlsjs {
|
||||||
error: null
|
error: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private audioMode = false
|
||||||
|
|
||||||
constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) {
|
constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) {
|
||||||
this.vjs = vjs
|
this.vjs = vjs
|
||||||
this.source = source
|
this.source = source
|
||||||
|
@ -206,50 +204,14 @@ export class Html5Hlsjs {
|
||||||
return this.vjs.createTimeRanges()
|
return this.vjs.createTimeRanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
// See comment for `initialize` method.
|
|
||||||
dispose () {
|
dispose () {
|
||||||
this.videoElement.removeEventListener('play', this.handlers.play)
|
this.videoElement.removeEventListener('play', this.handlers.play)
|
||||||
this.videoElement.removeEventListener('error', this.handlers.error)
|
this.videoElement.removeEventListener('error', this.handlers.error)
|
||||||
|
|
||||||
// FIXME: https://github.com/video-dev/hls.js/issues/4092
|
|
||||||
const untypedHLS = this.hls as any
|
|
||||||
untypedHLS.log = untypedHLS.warn = () => {
|
|
||||||
// empty
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hls.destroy()
|
this.hls.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
static addHook (type: string, callback: HookFn) {
|
// ---------------------------------------------------------------------------
|
||||||
Html5Hlsjs.hooks[type] = this.hooks[type] || []
|
|
||||||
Html5Hlsjs.hooks[type].push(callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeHook (type: string, callback: HookFn) {
|
|
||||||
if (Html5Hlsjs.hooks[type] === undefined) return false
|
|
||||||
|
|
||||||
const index = Html5Hlsjs.hooks[type].indexOf(callback)
|
|
||||||
if (index === -1) return false
|
|
||||||
|
|
||||||
Html5Hlsjs.hooks[type].splice(index, 1)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeAllHooks () {
|
|
||||||
Html5Hlsjs.hooks = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _executeHooksFor (type: string) {
|
|
||||||
if (Html5Hlsjs.hooks[type] === undefined) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ES3 and IE < 9
|
|
||||||
for (let i = 0; i < Html5Hlsjs.hooks[type].length; i++) {
|
|
||||||
Html5Hlsjs.hooks[type][i](this.player, this.hls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _getHumanErrorMsg (error: { message: string, code?: number }) {
|
private _getHumanErrorMsg (error: { message: string, code?: number }) {
|
||||||
switch (error.code) {
|
switch (error.code) {
|
||||||
|
@ -265,11 +227,14 @@ export class Html5Hlsjs {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hls.destroy()
|
this.hls.destroy()
|
||||||
|
|
||||||
logger.info('bubbling error up to VIDEOJS')
|
logger.info('bubbling error up to VIDEOJS')
|
||||||
|
|
||||||
this.tech.error = () => ({
|
this.tech.error = () => ({
|
||||||
...error,
|
...error,
|
||||||
message: this._getHumanErrorMsg(error)
|
message: this._getHumanErrorMsg(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.tech.trigger('error')
|
this.tech.trigger('error')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -335,16 +300,18 @@ export class Html5Hlsjs {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
private buildLevelLabel (level: Level) {
|
private buildLevelLabel (level: Level) {
|
||||||
if (this.player.srOptions_.levelLabelHandler) {
|
if (this.player.srOptions_.levelLabelHandler) {
|
||||||
return this.player.srOptions_.levelLabelHandler(level as any)
|
return this.player.srOptions_.levelLabelHandler(level, this.player)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (level.height) return level.height + 'p'
|
if (level.height) return level.height + 'p'
|
||||||
if (level.width) return Math.round(level.width * 9 / 16) + 'p'
|
if (level.width) return Math.round(level.width * 9 / 16) + 'p'
|
||||||
if (level.bitrate) return (level.bitrate / 1000) + 'kbps'
|
if (level.bitrate) return (level.bitrate / 1000) + 'kbps'
|
||||||
|
|
||||||
return '0'
|
return this.player.localize('Audio only')
|
||||||
}
|
}
|
||||||
|
|
||||||
private _removeQuality (index: number) {
|
private _removeQuality (index: number) {
|
||||||
|
@ -367,50 +334,61 @@ export class Html5Hlsjs {
|
||||||
label: this.buildLevelLabel(level),
|
label: this.buildLevelLabel(level),
|
||||||
selected: level.id === this.hls.manualLevel,
|
selected: level.id === this.hls.manualLevel,
|
||||||
|
|
||||||
|
selectCallback: () => 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: () => {
|
selectCallback: () => {
|
||||||
this.hls.currentLevel = index
|
if (this.audioMode) return
|
||||||
|
this.audioMode = true
|
||||||
|
|
||||||
|
this.updateToAudioOrVideo(audioTrackUrl)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
resolutions.push({
|
resolutions.push({
|
||||||
id: -1,
|
id: -1,
|
||||||
label: this.player.localize('Auto'),
|
label: this.player.localize('Auto'),
|
||||||
selected: true,
|
selected: true,
|
||||||
selectCallback: () => this.hls.currentLevel = -1
|
selectCallback: () => this.manuallySelectVideoLevel(-1)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.player.peertubeResolutions().add(resolutions)
|
this.player.peertubeResolutions().add(resolutions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private manuallySelectVideoLevel (index: number) {
|
||||||
|
if (this.audioMode) {
|
||||||
|
this.audioMode = false
|
||||||
|
this.updateToAudioOrVideo(this.source.src, index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hls.currentLevel = index
|
||||||
|
}
|
||||||
|
|
||||||
private _startLoad () {
|
private _startLoad () {
|
||||||
this.hls.startLoad(-1)
|
this.hls.startLoad(-1)
|
||||||
this.videoElement.removeEventListener('play', this.handlers.play)
|
this.videoElement.removeEventListener('play', this.handlers.play)
|
||||||
}
|
}
|
||||||
|
|
||||||
private _oneLevelObjClone (obj: { [ id: string ]: any }) {
|
|
||||||
const result: { [id: string]: any } = {}
|
|
||||||
const objKeys = Object.keys(obj)
|
|
||||||
for (let i = 0; i < objKeys.length; i++) {
|
|
||||||
result[objKeys[i]] = obj[objKeys[i]]
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onMetaData (_event: any, data: ManifestParsedData) {
|
private _onMetaData (_event: any, data: ManifestParsedData) {
|
||||||
// This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later
|
// This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later
|
||||||
this.metadata = data
|
this.metadata = data
|
||||||
this._notifyVideoQualities()
|
this._notifyVideoQualities()
|
||||||
}
|
}
|
||||||
|
|
||||||
private _initHlsjs () {
|
private initialize () {
|
||||||
const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions
|
this.buildBaseConfig()
|
||||||
const srOptions_ = this.player.srOptions_
|
|
||||||
|
|
||||||
const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig
|
|
||||||
// Hls.js will write to the reference thus change the object for later streams
|
|
||||||
this.hlsjsConfig = hlsjsConfigRef ? this._oneLevelObjClone(hlsjsConfigRef) : {}
|
|
||||||
|
|
||||||
if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) {
|
if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) {
|
||||||
this.hlsjsConfig.autoStartLoad = false
|
this.hlsjsConfig.autoStartLoad = false
|
||||||
|
@ -423,9 +401,10 @@ export class Html5Hlsjs {
|
||||||
this.videoElement.addEventListener('play', this.handlers.play)
|
this.videoElement.addEventListener('play', this.handlers.play)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hls = new Hlsjs(this.hlsjsConfig)
|
const loader = this.hlsjsConfig.loaderBuilder()
|
||||||
|
this.hls = new Hlsjs({ ...this.hlsjsConfig, loader })
|
||||||
|
|
||||||
this._executeHooksFor('beforeinitialize')
|
this.player.trigger('hlsjs-initialized', { hlsjs: this.hls, engine: loader.getEngine() })
|
||||||
|
|
||||||
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
|
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
|
||||||
this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data))
|
this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data))
|
||||||
|
@ -446,30 +425,83 @@ export class Html5Hlsjs {
|
||||||
if (this.isLive) this.maxNetworkErrorRecovery = 30
|
if (this.isLive) this.maxNetworkErrorRecovery = 30
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.registerLevelEventSwitch()
|
||||||
|
|
||||||
this.hls.once(Hlsjs.Events.FRAG_LOADED, () => {
|
this.hls.once(Hlsjs.Events.FRAG_LOADED, () => {
|
||||||
// Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls`
|
// Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls`
|
||||||
// Ref: https://github.com/videojs/videojs-contrib-hls#loadedmetadata
|
// Ref: https://github.com/videojs/videojs-contrib-hls#loadedmetadata
|
||||||
this.tech.trigger('loadedmetadata')
|
this.tech.trigger('loadedmetadata')
|
||||||
})
|
})
|
||||||
|
|
||||||
this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => {
|
|
||||||
const resolutionId = this.hls.autoLevelEnabled
|
|
||||||
? -1
|
|
||||||
: data.level
|
|
||||||
|
|
||||||
const autoResolutionChosenId = this.hls.autoLevelEnabled
|
|
||||||
? data.level
|
|
||||||
: -1
|
|
||||||
|
|
||||||
this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
this.hls.attachMedia(this.videoElement)
|
this.hls.attachMedia(this.videoElement)
|
||||||
|
|
||||||
this.hls.loadSource(this.source.src)
|
this.hls.loadSource(this.source.src)
|
||||||
}
|
}
|
||||||
|
|
||||||
private initialize () {
|
private updateToAudioOrVideo (newSource: string, startLevel?: number) {
|
||||||
this._initHlsjs()
|
this.player.addClass('vjs-updating-resolution')
|
||||||
|
|
||||||
|
const currentTime = this.player.currentTime()
|
||||||
|
|
||||||
|
this.dispose()
|
||||||
|
|
||||||
|
this.buildBaseConfig()
|
||||||
|
this.hlsjsConfig.autoStartLoad = true
|
||||||
|
this.player.autoplay('play')
|
||||||
|
|
||||||
|
const loader = this.hlsjsConfig.loaderBuilder()
|
||||||
|
this.hls = new Hlsjs({
|
||||||
|
...this.hlsjsConfig,
|
||||||
|
loader,
|
||||||
|
startPosition: this.duration() === Infinity
|
||||||
|
? undefined
|
||||||
|
: currentTime,
|
||||||
|
startLevel
|
||||||
|
})
|
||||||
|
|
||||||
|
this.player.trigger('hlsjs-initialized', { hlsjs: this.hls, engine: loader.getEngine() })
|
||||||
|
|
||||||
|
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
|
||||||
|
this.registerLevelEventSwitch()
|
||||||
|
|
||||||
|
this.hls.attachMedia(this.videoElement)
|
||||||
|
this.hls.loadSource(newSource)
|
||||||
|
|
||||||
|
this.player.one('canplay', () => {
|
||||||
|
this.player.removeClass('vjs-updating-resolution')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerLevelEventSwitch () {
|
||||||
|
this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => {
|
||||||
|
let resolutionId = data.level
|
||||||
|
let autoResolutionChosenId = -1
|
||||||
|
|
||||||
|
if (this.audioMode) {
|
||||||
|
resolutionId = -2
|
||||||
|
} else if (this.hls.autoLevelEnabled) {
|
||||||
|
resolutionId = -1
|
||||||
|
autoResolutionChosenId = data.level
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBaseConfig () {
|
||||||
|
const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions
|
||||||
|
const srOptions_ = this.player.srOptions_
|
||||||
|
|
||||||
|
const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig
|
||||||
|
|
||||||
|
// Hls.js will write to the reference thus change the object for later streams
|
||||||
|
this.hlsjsConfig = hlsjsConfigRef
|
||||||
|
? { ...hlsjsConfigRef }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSeparateAudioTrack () {
|
||||||
|
if (this.metadata.audioTracks.length === 0) return undefined
|
||||||
|
|
||||||
|
return this.metadata.audioTracks[0].url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,9 @@ import Hlsjs from 'hls.js'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
|
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
|
||||||
import { SettingsButton } from '../settings/settings-menu-button'
|
import { SettingsButton } from '../settings/settings-menu-button'
|
||||||
|
import debug from 'debug'
|
||||||
|
|
||||||
|
const debugLogger = debug('peertube:player:p2p-media-loader')
|
||||||
|
|
||||||
const Plugin = videojs.getPlugin('plugin')
|
const Plugin = videojs.getPlugin('plugin')
|
||||||
class P2pMediaLoaderPlugin extends Plugin {
|
class P2pMediaLoaderPlugin extends Plugin {
|
||||||
|
@ -56,19 +59,23 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
|
player.on('hlsjs-initialized', (_: any, { hlsjs, engine }) => {
|
||||||
(videojs as any).Html5Hlsjs.addHook('beforeinitialize', (_videojsPlayer: any, hlsjs: any) => {
|
this.p2pEngine?.removeAllListeners()
|
||||||
|
this.p2pEngine?.destroy()
|
||||||
|
clearInterval(this.networkInfoInterval)
|
||||||
|
|
||||||
this.hlsjs = hlsjs
|
this.hlsjs = hlsjs
|
||||||
|
this.p2pEngine = engine
|
||||||
|
|
||||||
|
debugLogger('hls.js initialized, initializing p2p-media-loader plugin', { hlsjs, engine })
|
||||||
|
|
||||||
|
player.ready(() => this.initializePlugin())
|
||||||
})
|
})
|
||||||
|
|
||||||
player.src({
|
player.src({
|
||||||
type: options.type,
|
type: options.type,
|
||||||
src: options.src
|
src: options.src
|
||||||
})
|
})
|
||||||
|
|
||||||
player.ready(() => {
|
|
||||||
this.initializePlugin()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose () {
|
dispose () {
|
||||||
|
@ -76,9 +83,7 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
this.p2pEngine?.destroy()
|
this.p2pEngine?.destroy()
|
||||||
|
|
||||||
this.hlsjs?.destroy()
|
this.hlsjs?.destroy()
|
||||||
this.options.segmentValidator?.destroy();
|
this.options.segmentValidator?.destroy()
|
||||||
|
|
||||||
(videojs as any).Html5Hlsjs?.removeAllHooks()
|
|
||||||
|
|
||||||
clearInterval(this.networkInfoInterval)
|
clearInterval(this.networkInfoInterval)
|
||||||
|
|
||||||
|
@ -112,8 +117,6 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
private initializePlugin () {
|
private initializePlugin () {
|
||||||
initHlsJsPlayer(this.player, this.hlsjs)
|
initHlsJsPlayer(this.player, this.hlsjs)
|
||||||
|
|
||||||
this.p2pEngine = this.options.loader.getEngine()
|
|
||||||
|
|
||||||
this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => {
|
this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => {
|
||||||
if (navigator.onLine === false) return
|
if (navigator.onLine === false) return
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,9 @@ import { logger } from '@root-helpers/logger'
|
||||||
import { wait } from '@root-helpers/utils'
|
import { wait } from '@root-helpers/utils'
|
||||||
import { removeQueryParams } from '@peertube/peertube-core-utils'
|
import { removeQueryParams } from '@peertube/peertube-core-utils'
|
||||||
import { isSameOrigin } from '../common'
|
import { isSameOrigin } from '../common'
|
||||||
|
import debug from 'debug'
|
||||||
|
|
||||||
|
const debugLogger = debug('peertube:player:segment-validator')
|
||||||
|
|
||||||
type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
|
type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
|
||||||
|
|
||||||
|
@ -67,6 +70,8 @@ export class SegmentValidator {
|
||||||
throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
|
throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugLogger(`Validating ${filename} range ${segment.range}`)
|
||||||
|
|
||||||
const calculatedSha = await this.sha256Hex(segment.data)
|
const calculatedSha = await this.sha256Hex(segment.data)
|
||||||
if (calculatedSha !== hashShouldBe) {
|
if (calculatedSha !== hashShouldBe) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
|
@ -4,7 +4,13 @@ import { LiveVideoLatencyMode } from '@peertube/peertube-models'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
||||||
import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
|
import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
|
||||||
import { P2PMediaLoader, P2PMediaLoaderPluginOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions } from '../../types'
|
import {
|
||||||
|
HLSLoaderClass,
|
||||||
|
HLSPluginOptions,
|
||||||
|
P2PMediaLoaderPluginOptions,
|
||||||
|
PeerTubePlayerContructorOptions,
|
||||||
|
PeerTubePlayerLoadOptions
|
||||||
|
} from '../../types'
|
||||||
import { getRtcConfig, isSameOrigin } from '../common'
|
import { getRtcConfig, isSameOrigin } from '../common'
|
||||||
import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
|
import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
|
||||||
import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder'
|
import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder'
|
||||||
|
@ -47,7 +53,7 @@ export class HLSOptionsBuilder {
|
||||||
'filter:internal.player.p2p-media-loader.options.result',
|
'filter:internal.player.p2p-media-loader.options.result',
|
||||||
this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator })
|
this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator })
|
||||||
)
|
)
|
||||||
const loader = new Engine(p2pMediaLoaderConfig).createLoaderClass() as unknown as P2PMediaLoader
|
const loaderBuilder = () => new Engine(p2pMediaLoaderConfig).createLoaderClass() as unknown as HLSLoaderClass
|
||||||
|
|
||||||
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
|
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
|
||||||
requiresUserAuth: this.options.requiresUserAuth,
|
requiresUserAuth: this.options.requiresUserAuth,
|
||||||
|
@ -58,19 +64,22 @@ export class HLSOptionsBuilder {
|
||||||
redundancyUrlManager,
|
redundancyUrlManager,
|
||||||
type: 'application/x-mpegURL',
|
type: 'application/x-mpegURL',
|
||||||
src: this.options.hls.playlistUrl,
|
src: this.options.hls.playlistUrl,
|
||||||
segmentValidator,
|
segmentValidator
|
||||||
loader
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hlsjs = {
|
const hlsjs = {
|
||||||
hlsjsConfig: this.getHLSJSOptions(loader),
|
hlsjsConfig: this.getHLSJSOptions(loaderBuilder),
|
||||||
|
|
||||||
levelLabelHandler: (level: { height: number, width: number }) => {
|
levelLabelHandler: (level: { height: number, width: number }, player: videojs.VideoJsPlayer) => {
|
||||||
const resolution = Math.min(level.height || 0, level.width || 0)
|
const resolution = Math.min(level.height || 0, level.width || 0)
|
||||||
|
|
||||||
const file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution)
|
const file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution)
|
||||||
// We don't have files for live videos
|
// We don't have files for live videos
|
||||||
if (!file) return level.height
|
if (!file) {
|
||||||
|
if (resolution === 0) return player.localize('Audio only')
|
||||||
|
|
||||||
|
return level.height + 'p'
|
||||||
|
}
|
||||||
|
|
||||||
let label = file.resolution.label
|
let label = file.resolution.label
|
||||||
if (file.fps >= 50) label += file.fps
|
if (file.fps >= 50) label += file.fps
|
||||||
|
@ -185,7 +194,7 @@ export class HLSOptionsBuilder {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
private getHLSJSOptions (loader: P2PMediaLoader) {
|
private getHLSJSOptions (loaderBuilder: () => HLSLoaderClass): HLSPluginOptions {
|
||||||
const specificLiveOrVODOptions = this.options.isLive
|
const specificLiveOrVODOptions = this.options.isLive
|
||||||
? this.getHLSLiveOptions()
|
? this.getHLSLiveOptions()
|
||||||
: this.getHLSVODOptions()
|
: this.getHLSVODOptions()
|
||||||
|
@ -194,7 +203,7 @@ export class HLSOptionsBuilder {
|
||||||
capLevelToPlayerSize: true,
|
capLevelToPlayerSize: true,
|
||||||
autoStartLoad: false,
|
autoStartLoad: false,
|
||||||
|
|
||||||
loader,
|
loaderBuilder,
|
||||||
|
|
||||||
...specificLiveOrVODOptions
|
...specificLiveOrVODOptions
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,9 @@ class PeerTubeResolutionsPlugin extends Plugin {
|
||||||
|
|
||||||
if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return
|
if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return
|
||||||
|
|
||||||
|
if (autoResolutionChosenId !== undefined) {
|
||||||
this.autoResolutionChosenId = autoResolutionChosenId
|
this.autoResolutionChosenId = autoResolutionChosenId
|
||||||
|
}
|
||||||
|
|
||||||
for (const r of this.resolutions) {
|
for (const r of this.resolutions) {
|
||||||
r.selected = r.id === id
|
r.selected = r.id === id
|
||||||
|
|
|
@ -42,7 +42,7 @@ class ResolutionMenuButton extends MenuButton {
|
||||||
|
|
||||||
for (const r of resolutions) {
|
for (const r of resolutions) {
|
||||||
const label = r.label === '0p'
|
const label = r.label === '0p'
|
||||||
? this.player().localize('Audio-only')
|
? this.player().localize('Audio only')
|
||||||
: r.label
|
: r.label
|
||||||
|
|
||||||
const component = new ResolutionMenuItem(
|
const component = new ResolutionMenuItem(
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { HlsConfig, Level } from 'hls.js'
|
|
||||||
import videojs from 'video.js'
|
|
||||||
import { Engine } from '@peertube/p2p-media-loader-hlsjs'
|
import { Engine } from '@peertube/p2p-media-loader-hlsjs'
|
||||||
import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
|
import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
|
||||||
|
import type { HlsConfig, Level, Loader, LoaderContext } from 'hls.js'
|
||||||
|
import videojs from 'video.js'
|
||||||
import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
|
import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
|
||||||
|
import { ContextMenuPlugin } from '../shared/context-menu'
|
||||||
|
import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
|
||||||
import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
|
import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
|
||||||
import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
|
import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
|
||||||
import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin'
|
import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin'
|
||||||
|
@ -10,6 +12,7 @@ import { PeerTubeMobilePlugin } from '../shared/mobile/peertube-mobile-plugin'
|
||||||
import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
|
import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
|
||||||
import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
|
import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
|
||||||
import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
|
import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
|
||||||
|
import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
|
||||||
import { PeerTubePlugin } from '../shared/peertube/peertube-plugin'
|
import { PeerTubePlugin } from '../shared/peertube/peertube-plugin'
|
||||||
import { PlaylistPlugin } from '../shared/playlist/playlist-plugin'
|
import { PlaylistPlugin } from '../shared/playlist/playlist-plugin'
|
||||||
import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin'
|
import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin'
|
||||||
|
@ -18,9 +21,6 @@ import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin'
|
||||||
import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
|
import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
|
||||||
import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
|
import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
|
||||||
import { PlayerMode } from './peertube-player-options'
|
import { PlayerMode } from './peertube-player-options'
|
||||||
import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
|
|
||||||
import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
|
|
||||||
import { ContextMenuPlugin } from '../shared/context-menu'
|
|
||||||
|
|
||||||
declare module 'video.js' {
|
declare module 'video.js' {
|
||||||
|
|
||||||
|
@ -79,10 +79,10 @@ export interface VideoJSTechHLS extends videojs.Tech {
|
||||||
export interface HlsjsConfigHandlerOptions {
|
export interface HlsjsConfigHandlerOptions {
|
||||||
hlsjsConfig?: HlsConfig
|
hlsjsConfig?: HlsConfig
|
||||||
|
|
||||||
levelLabelHandler?: (level: Level) => string
|
levelLabelHandler?: (level: Level, player: videojs.Player) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeerTubeResolution = {
|
export type PeerTubeResolution = {
|
||||||
id: number
|
id: number
|
||||||
|
|
||||||
height?: number
|
height?: number
|
||||||
|
@ -94,21 +94,21 @@ type PeerTubeResolution = {
|
||||||
selectCallback: () => void
|
selectCallback: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoJSCaption = {
|
export type VideoJSCaption = {
|
||||||
label: string
|
label: string
|
||||||
language: string
|
language: string
|
||||||
src: string
|
src: string
|
||||||
automaticallyGenerated: boolean
|
automaticallyGenerated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoJSStoryboard = {
|
export type VideoJSStoryboard = {
|
||||||
url: string
|
url: string
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
interval: number
|
interval: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeerTubePluginOptions = {
|
export type PeerTubePluginOptions = {
|
||||||
autoPlayerRatio: {
|
autoPlayerRatio: {
|
||||||
cssRatioVariable: string
|
cssRatioVariable: string
|
||||||
cssPlayerPortraitModeVariable: string
|
cssPlayerPortraitModeVariable: string
|
||||||
|
@ -136,14 +136,14 @@ type PeerTubePluginOptions = {
|
||||||
poster: () => string
|
poster: () => string
|
||||||
}
|
}
|
||||||
|
|
||||||
type MetricsPluginOptions = {
|
export type MetricsPluginOptions = {
|
||||||
mode: () => PlayerMode
|
mode: () => PlayerMode
|
||||||
metricsUrl: () => string
|
metricsUrl: () => string
|
||||||
metricsInterval: () => number
|
metricsInterval: () => number
|
||||||
videoUUID: () => string
|
videoUUID: () => string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextMenuPluginOptions = {
|
export type ContextMenuPluginOptions = {
|
||||||
content: () => {
|
content: () => {
|
||||||
icon?: string
|
icon?: string
|
||||||
label: string
|
label: string
|
||||||
|
@ -151,23 +151,23 @@ type ContextMenuPluginOptions = {
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextMenuItemOptions = {
|
export type ContextMenuItemOptions = {
|
||||||
listener: (e: videojs.EventTarget.Event) => void
|
listener: (e: videojs.EventTarget.Event) => void
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoryboardOptions = {
|
export type StoryboardOptions = {
|
||||||
url: string
|
url: string
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
interval: number
|
interval: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChaptersOptions = {
|
export type ChaptersOptions = {
|
||||||
chapters: VideoChapter[]
|
chapters: VideoChapter[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaylistPluginOptions = {
|
export type PlaylistPluginOptions = {
|
||||||
elements: VideoPlaylistElement[]
|
elements: VideoPlaylistElement[]
|
||||||
|
|
||||||
playlist: VideoPlaylist
|
playlist: VideoPlaylist
|
||||||
|
@ -177,7 +177,7 @@ type PlaylistPluginOptions = {
|
||||||
onItemClicked: (element: VideoPlaylistElement) => void
|
onItemClicked: (element: VideoPlaylistElement) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpNextPluginOptions = {
|
export type UpNextPluginOptions = {
|
||||||
timeout: number
|
timeout: number
|
||||||
|
|
||||||
next: () => void
|
next: () => void
|
||||||
|
@ -186,33 +186,40 @@ type UpNextPluginOptions = {
|
||||||
isSuspended: () => boolean
|
isSuspended: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProgressBarMarkerComponentOptions = {
|
export type ProgressBarMarkerComponentOptions = {
|
||||||
timecode: number
|
timecode: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type NextPreviousVideoButtonOptions = {
|
export type NextPreviousVideoButtonOptions = {
|
||||||
type: 'next' | 'previous'
|
type: 'next' | 'previous'
|
||||||
handler?: () => void
|
handler?: () => void
|
||||||
isDisplayed: () => boolean
|
isDisplayed: () => boolean
|
||||||
isDisabled: () => boolean
|
isDisabled: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeerTubeLinkButtonOptions = {
|
export type PeerTubeLinkButtonOptions = {
|
||||||
isDisplayed: () => boolean
|
isDisplayed: () => boolean
|
||||||
shortUUID: () => string
|
shortUUID: () => string
|
||||||
instanceName: string
|
instanceName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TheaterButtonOptions = {
|
export type TheaterButtonOptions = {
|
||||||
isDisplayed: () => boolean
|
isDisplayed: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebVideoPluginOptions = {
|
export type WebVideoPluginOptions = {
|
||||||
videoFiles: VideoFile[]
|
videoFiles: VideoFile[]
|
||||||
videoFileToken: () => string
|
videoFileToken: () => string
|
||||||
}
|
}
|
||||||
|
|
||||||
type P2PMediaLoaderPluginOptions = {
|
export type HLSLoaderClass = {
|
||||||
|
new (confg: HlsConfig): Loader<LoaderContext>
|
||||||
|
|
||||||
|
getEngine(): Engine
|
||||||
|
}
|
||||||
|
export type HLSPluginOptions = Partial<HlsConfig & { cueHandler: any, loaderBuilder: () => HLSLoaderClass }>
|
||||||
|
|
||||||
|
export type P2PMediaLoaderPluginOptions = {
|
||||||
redundancyUrlManager: RedundancyUrlManager | null
|
redundancyUrlManager: RedundancyUrlManager | null
|
||||||
segmentValidator: SegmentValidator | null
|
segmentValidator: SegmentValidator | null
|
||||||
|
|
||||||
|
@ -221,8 +228,6 @@ type P2PMediaLoaderPluginOptions = {
|
||||||
|
|
||||||
p2pEnabled: boolean
|
p2pEnabled: boolean
|
||||||
|
|
||||||
loader: P2PMediaLoader
|
|
||||||
|
|
||||||
requiresUserAuth: boolean
|
requiresUserAuth: boolean
|
||||||
videoFileToken: () => string
|
videoFileToken: () => string
|
||||||
}
|
}
|
||||||
|
@ -233,7 +238,7 @@ export type P2PMediaLoader = {
|
||||||
destroy: () => void
|
destroy: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoJSPluginOptions = {
|
export type VideoJSPluginOptions = {
|
||||||
playlist?: PlaylistPluginOptions
|
playlist?: PlaylistPluginOptions
|
||||||
|
|
||||||
peertube: PeerTubePluginOptions
|
peertube: PeerTubePluginOptions
|
||||||
|
@ -244,7 +249,7 @@ type VideoJSPluginOptions = {
|
||||||
p2pMediaLoader?: P2PMediaLoaderPluginOptions
|
p2pMediaLoader?: P2PMediaLoaderPluginOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadedQualityData = {
|
export type LoadedQualityData = {
|
||||||
qualitySwitchCallback: (resolutionId: number, type: 'video') => void
|
qualitySwitchCallback: (resolutionId: number, type: 'video') => void
|
||||||
qualityData: {
|
qualityData: {
|
||||||
video: {
|
video: {
|
||||||
|
@ -255,17 +260,17 @@ type LoadedQualityData = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResolutionUpdateData = {
|
export type ResolutionUpdateData = {
|
||||||
auto: boolean
|
auto: boolean
|
||||||
resolutionId: number
|
resolutionId: number
|
||||||
id?: number
|
id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type AutoResolutionUpdateData = {
|
export type AutoResolutionUpdateData = {
|
||||||
possible: boolean
|
possible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayerNetworkInfo = {
|
export type PlayerNetworkInfo = {
|
||||||
source: 'web-video' | 'p2p-media-loader'
|
source: 'web-video' | 'p2p-media-loader'
|
||||||
|
|
||||||
http: {
|
http: {
|
||||||
|
@ -288,34 +293,8 @@ type PlayerNetworkInfo = {
|
||||||
bandwidthEstimate?: number
|
bandwidthEstimate?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaylistItemOptions = {
|
export type PlaylistItemOptions = {
|
||||||
element: VideoPlaylistElement
|
element: VideoPlaylistElement
|
||||||
|
|
||||||
onClicked: () => void
|
onClicked: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
|
||||||
PlayerNetworkInfo,
|
|
||||||
TheaterButtonOptions,
|
|
||||||
VideoJSStoryboard,
|
|
||||||
PlaylistItemOptions,
|
|
||||||
NextPreviousVideoButtonOptions,
|
|
||||||
ResolutionUpdateData,
|
|
||||||
AutoResolutionUpdateData,
|
|
||||||
ProgressBarMarkerComponentOptions,
|
|
||||||
PlaylistPluginOptions,
|
|
||||||
MetricsPluginOptions,
|
|
||||||
VideoJSCaption,
|
|
||||||
PeerTubePluginOptions,
|
|
||||||
WebVideoPluginOptions,
|
|
||||||
P2PMediaLoaderPluginOptions,
|
|
||||||
ContextMenuItemOptions,
|
|
||||||
PeerTubeResolution,
|
|
||||||
VideoJSPluginOptions,
|
|
||||||
ContextMenuPluginOptions,
|
|
||||||
UpNextPluginOptions,
|
|
||||||
LoadedQualityData,
|
|
||||||
StoryboardOptions,
|
|
||||||
ChaptersOptions,
|
|
||||||
PeerTubeLinkButtonOptions
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_BACKEND_URL="http://localhost:9000"
|
|
@ -24,7 +24,8 @@ import {
|
||||||
PlaylistFetcher,
|
PlaylistFetcher,
|
||||||
PlaylistTracker,
|
PlaylistTracker,
|
||||||
Translations,
|
Translations,
|
||||||
VideoFetcher
|
VideoFetcher,
|
||||||
|
getBackendUrl
|
||||||
} from './shared'
|
} from './shared'
|
||||||
import { PlayerHTML } from './shared/player-html'
|
import { PlayerHTML } from './shared/player-html'
|
||||||
|
|
||||||
|
@ -58,7 +59,7 @@ export class PeerTubeEmbed {
|
||||||
private requiresPassword: boolean
|
private requiresPassword: boolean
|
||||||
|
|
||||||
constructor (videoWrapperId: string) {
|
constructor (videoWrapperId: string) {
|
||||||
logger.registerServerSending(window.location.origin)
|
logger.registerServerSending(getBackendUrl())
|
||||||
|
|
||||||
this.http = new AuthHTTP()
|
this.http = new AuthHTTP()
|
||||||
|
|
||||||
|
@ -73,9 +74,11 @@ export class PeerTubeEmbed {
|
||||||
try {
|
try {
|
||||||
this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
|
this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!(import.meta as any).env.DEV) {
|
||||||
logger.error('Cannot parse HTML config.', err)
|
logger.error('Cannot parse HTML config.', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async main () {
|
static async main () {
|
||||||
const videoContainerId = 'video-wrapper'
|
const videoContainerId = 'video-wrapper'
|
||||||
|
@ -90,12 +93,12 @@ export class PeerTubeEmbed {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async init () {
|
async init () {
|
||||||
this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
|
this.translationsPromise = TranslationsManager.getServerTranslations(getBackendUrl(), navigator.language)
|
||||||
this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player')
|
this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player')
|
||||||
|
|
||||||
// Issue when we parsed config from HTML, fallback to API
|
// Issue when we parsed config from HTML, fallback to API
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
this.config = await this.http.fetch('/api/v1/config', { optionalAuth: false })
|
this.config = await this.http.fetch(getBackendUrl() + '/api/v1/config', { optionalAuth: false })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,7 +268,7 @@ export class PeerTubeEmbed {
|
||||||
|
|
||||||
// If already played, we are in a playlist so we don't want to display the poster between videos
|
// If already played, we are in a playlist so we don't want to display the poster between videos
|
||||||
if (!this.alreadyPlayed) {
|
if (!this.alreadyPlayed) {
|
||||||
this.peertubePlayer.setPoster(window.location.origin + video.previewPath)
|
this.peertubePlayer.setPoster(getBackendUrl() + video.previewPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const playlist = this.playlistTracker
|
const playlist = this.playlistTracker
|
||||||
|
@ -351,6 +354,16 @@ export class PeerTubeEmbed {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
private getResourceId () {
|
private getResourceId () {
|
||||||
|
const search = window.location.search
|
||||||
|
|
||||||
|
if (search.startsWith('?videoId=')) {
|
||||||
|
return search.replace(/^\?videoId=/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search.startsWith('?videoPlaylistId=')) {
|
||||||
|
return search.replace(/^\?videoPlaylistId=/, '')
|
||||||
|
}
|
||||||
|
|
||||||
const urlParts = window.location.pathname.split('/')
|
const urlParts = window.location.pathname.split('/')
|
||||||
return urlParts[urlParts.length - 1]
|
return urlParts[urlParts.length - 1]
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,5 +5,6 @@ export * from './player-html'
|
||||||
export * from './player-options-builder'
|
export * from './player-options-builder'
|
||||||
export * from './playlist-fetcher'
|
export * from './playlist-fetcher'
|
||||||
export * from './playlist-tracker'
|
export * from './playlist-tracker'
|
||||||
|
export * from './url'
|
||||||
export * from './translations'
|
export * from './translations'
|
||||||
export * from './video-fetcher'
|
export * from './video-fetcher'
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { Socket } from 'socket.io-client'
|
|
||||||
import { LiveVideoEventPayload, VideoDetails, VideoState, VideoStateType } from '@peertube/peertube-models'
|
import { LiveVideoEventPayload, VideoDetails, VideoState, VideoStateType } from '@peertube/peertube-models'
|
||||||
|
import { Socket } from 'socket.io-client'
|
||||||
import { PlayerHTML } from './player-html'
|
import { PlayerHTML } from './player-html'
|
||||||
import { Translations } from './translations'
|
import { Translations } from './translations'
|
||||||
|
import { getBackendUrl } from './url'
|
||||||
|
|
||||||
export class LiveManager {
|
export class LiveManager {
|
||||||
private liveSocket: Socket
|
private liveSocket: Socket
|
||||||
|
@ -22,7 +23,7 @@ export class LiveManager {
|
||||||
|
|
||||||
if (!this.liveSocket) {
|
if (!this.liveSocket) {
|
||||||
const io = (await import('socket.io-client')).io
|
const io = (await import('socket.io-client')).io
|
||||||
this.liveSocket = io(window.location.origin + '/live-videos')
|
this.liveSocket = io(getBackendUrl() + '/live-videos')
|
||||||
}
|
}
|
||||||
|
|
||||||
const listener = (payload: LiveVideoEventPayload) => {
|
const listener = (payload: LiveVideoEventPayload) => {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { PluginInfo, PluginsManager } from '../../../root-helpers'
|
||||||
import { RegisterClientHelpers } from '../../../types'
|
import { RegisterClientHelpers } from '../../../types'
|
||||||
import { AuthHTTP } from './auth-http'
|
import { AuthHTTP } from './auth-http'
|
||||||
import { Translations } from './translations'
|
import { Translations } from './translations'
|
||||||
|
import { getBackendUrl } from './url'
|
||||||
|
|
||||||
export class PeerTubePlugin {
|
export class PeerTubePlugin {
|
||||||
|
|
||||||
|
@ -83,6 +84,6 @@ export class PeerTubePlugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPluginUrl () {
|
private getPluginUrl () {
|
||||||
return window.location.origin + '/api/v1/plugins'
|
return getBackendUrl() + '/api/v1/plugins'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@ import { PlayerHTML } from './player-html'
|
||||||
import { PlaylistTracker } from './playlist-tracker'
|
import { PlaylistTracker } from './playlist-tracker'
|
||||||
import { Translations } from './translations'
|
import { Translations } from './translations'
|
||||||
import { VideoFetcher } from './video-fetcher'
|
import { VideoFetcher } from './video-fetcher'
|
||||||
|
import { getBackendUrl } from './url'
|
||||||
|
|
||||||
export class PlayerOptionsBuilder {
|
export class PlayerOptionsBuilder {
|
||||||
private autoplay: boolean
|
private autoplay: boolean
|
||||||
|
@ -190,7 +191,7 @@ export class PlayerOptionsBuilder {
|
||||||
videoViewIntervalMs: serverConfig.views.videos.watchingInterval.anonymous,
|
videoViewIntervalMs: serverConfig.views.videos.watchingInterval.anonymous,
|
||||||
|
|
||||||
metricsUrl: serverConfig.openTelemetry.metrics.enabled
|
metricsUrl: serverConfig.openTelemetry.metrics.enabled
|
||||||
? window.location.origin + '/api/v1/metrics/playback'
|
? getBackendUrl() + '/api/v1/metrics/playback'
|
||||||
: null,
|
: null,
|
||||||
metricsInterval: serverConfig.openTelemetry.metrics.playbackStatsInterval,
|
metricsInterval: serverConfig.openTelemetry.metrics.playbackStatsInterval,
|
||||||
|
|
||||||
|
@ -204,7 +205,7 @@ export class PlayerOptionsBuilder {
|
||||||
|
|
||||||
theaterButton: false,
|
theaterButton: false,
|
||||||
|
|
||||||
serverUrl: window.location.origin,
|
serverUrl: getBackendUrl(),
|
||||||
language: navigator.language,
|
language: navigator.language,
|
||||||
|
|
||||||
pluginsManager: this.peertubePlugin.getPluginsManager(),
|
pluginsManager: this.peertubePlugin.getPluginsManager(),
|
||||||
|
@ -292,9 +293,9 @@ export class PlayerOptionsBuilder {
|
||||||
duration: video.duration,
|
duration: video.duration,
|
||||||
videoRatio: video.aspectRatio,
|
videoRatio: video.aspectRatio,
|
||||||
|
|
||||||
poster: window.location.origin + video.previewPath,
|
poster: getBackendUrl() + video.previewPath,
|
||||||
|
|
||||||
embedUrl: window.location.origin + video.embedPath,
|
embedUrl: getBackendUrl() + video.embedPath,
|
||||||
embedTitle: video.name,
|
embedTitle: video.name,
|
||||||
|
|
||||||
requiresUserAuth: videoRequiresUserAuth(video),
|
requiresUserAuth: videoRequiresUserAuth(video),
|
||||||
|
@ -333,7 +334,7 @@ export class PlayerOptionsBuilder {
|
||||||
if (!storyboards || storyboards.length === 0) return undefined
|
if (!storyboards || storyboards.length === 0) return undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: window.location.origin + storyboards[0].storyboardPath,
|
url: getBackendUrl() + storyboards[0].storyboardPath,
|
||||||
height: storyboards[0].spriteHeight,
|
height: storyboards[0].spriteHeight,
|
||||||
width: storyboards[0].spriteWidth,
|
width: storyboards[0].spriteWidth,
|
||||||
interval: storyboards[0].spriteDuration
|
interval: storyboards[0].spriteDuration
|
||||||
|
@ -426,7 +427,7 @@ export class PlayerOptionsBuilder {
|
||||||
label: peertubeTranslate(c.language.label, translations),
|
label: peertubeTranslate(c.language.label, translations),
|
||||||
language: c.language.id,
|
language: c.language.id,
|
||||||
automaticallyGenerated: c.automaticallyGenerated,
|
automaticallyGenerated: c.automaticallyGenerated,
|
||||||
src: window.location.origin + c.captionPath
|
src: getBackendUrl() + c.captionPath
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { HttpStatusCode, ResultList, VideoPlaylistElement } from '@peertube/peertube-models'
|
import { HttpStatusCode, ResultList, VideoPlaylistElement } from '@peertube/peertube-models'
|
||||||
import { logger } from '../../../root-helpers'
|
import { logger } from '../../../root-helpers'
|
||||||
import { AuthHTTP } from './auth-http'
|
import { AuthHTTP } from './auth-http'
|
||||||
|
import { getBackendUrl } from './url'
|
||||||
|
|
||||||
export class PlaylistFetcher {
|
export class PlaylistFetcher {
|
||||||
|
|
||||||
|
@ -68,6 +69,6 @@ export class PlaylistFetcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPlaylistUrl (id: string) {
|
private getPlaylistUrl (id: string) {
|
||||||
return window.location.origin + '/api/v1/video-playlists/' + id
|
return getBackendUrl() + '/api/v1/video-playlists/' + id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { logger } from '../../../root-helpers'
|
||||||
import { PeerTubeServerError } from '../../../types'
|
import { PeerTubeServerError } from '../../../types'
|
||||||
import { AuthHTTP } from './auth-http'
|
import { AuthHTTP } from './auth-http'
|
||||||
|
import { getBackendUrl } from './url'
|
||||||
|
|
||||||
export class VideoFetcher {
|
export class VideoFetcher {
|
||||||
|
|
||||||
|
@ -70,11 +71,11 @@ export class VideoFetcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getVideoUrl (id: string) {
|
private getVideoUrl (id: string) {
|
||||||
return window.location.origin + '/api/v1/videos/' + id
|
return getBackendUrl() + '/api/v1/videos/' + id
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLiveUrl (videoId: string) {
|
private getLiveUrl (videoId: string) {
|
||||||
return window.location.origin + '/api/v1/videos/live/' + videoId
|
return getBackendUrl() + '/api/v1/videos/live/' + videoId
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadStoryboards (videoUUID: string): Promise<Response> {
|
private loadStoryboards (videoUUID: string): Promise<Response> {
|
||||||
|
@ -82,7 +83,7 @@ export class VideoFetcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStoryboardsUrl (videoId: string) {
|
private getStoryboardsUrl (videoId: string) {
|
||||||
return window.location.origin + '/api/v1/videos/' + videoId + '/storyboards'
|
return getBackendUrl() + '/api/v1/videos/' + videoId + '/storyboards'
|
||||||
}
|
}
|
||||||
|
|
||||||
private getVideoTokenUrl (id: string) {
|
private getVideoTokenUrl (id: string) {
|
||||||
|
|
|
@ -9,15 +9,40 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
const root = resolve(__dirname, '../../../')
|
const root = resolve(__dirname, '../../../')
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(({ mode }) => {
|
||||||
return {
|
return {
|
||||||
base: '/client/standalone/videos/',
|
base: mode === 'development'
|
||||||
|
? ''
|
||||||
|
: '/client/standalone/videos/',
|
||||||
|
|
||||||
root: resolve(root, 'src', 'standalone', 'videos'),
|
root: resolve(root, 'src', 'standalone', 'videos'),
|
||||||
|
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'^/(videos|video-playlists)/(test-)?embed/[^\/\.]+$': {
|
||||||
|
target: 'http://localhost:5173',
|
||||||
|
rewrite: (path) => {
|
||||||
|
return path.replace('/videos/embed/', 'embed.html?videoId=')
|
||||||
|
.replace('/videos/test-embed/', 'test-embed.html?')
|
||||||
|
.replace('/video-playlists/embed/', 'embed.html?videoPlaylistId=')
|
||||||
|
.replace('/video-playlists/test-embed/', 'test-embed.html?videoPlaylistId=')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'^/(videos|video-playlists)/(test-)?embed/.*': {
|
||||||
|
target: 'http://localhost:5173',
|
||||||
|
rewrite: (path) => {
|
||||||
|
return path.replace(/\/(videos|video-playlists)\/(test-)?embed\//, '')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'^/lazy-static': {
|
||||||
|
target: 'http://localhost:9000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: [
|
alias: [
|
||||||
{ find: /^video.js$/, replacement: resolve(root, './node_modules/video.js/core.js') },
|
{ find: /^video.js$/, replacement: resolve(root, './node_modules/video.js/core.js') },
|
||||||
{ find: /^hls.js$/, replacement: resolve(root, './node_modules/hls.js/dist/hls.light.mjs') },
|
|
||||||
{ find: '@root-helpers', replacement: resolve(root, './src/root-helpers') }
|
{ find: '@root-helpers', replacement: resolve(root, './src/root-helpers') }
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -33,6 +58,7 @@ export default defineConfig(() => {
|
||||||
build: {
|
build: {
|
||||||
outDir: resolve(root, 'dist', 'standalone', 'videos'),
|
outDir: resolve(root, 'dist', 'standalone', 'videos'),
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
sourcemap: mode === 'development',
|
||||||
|
|
||||||
target: [ 'firefox78', 'ios12' ],
|
target: [ 'firefox78', 'ios12' ],
|
||||||
|
|
||||||
|
|
|
@ -28,9 +28,6 @@
|
||||||
],
|
],
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
"hls.js": [
|
|
||||||
"node_modules/hls.js/dist/hls.light"
|
|
||||||
],
|
|
||||||
"video.js": [
|
"video.js": [
|
||||||
"node_modules/video.js/core"
|
"node_modules/video.js/core"
|
||||||
],
|
],
|
||||||
|
|
|
@ -56,6 +56,11 @@ rates_limit:
|
||||||
# 500 attempts in 10 seconds (to not break crawlers)
|
# 500 attempts in 10 seconds (to not break crawlers)
|
||||||
window: 10 seconds
|
window: 10 seconds
|
||||||
max: 500
|
max: 500
|
||||||
|
download_generate_video: # A light FFmpeg process is used to generate videos (to merge audio and video streams for example)
|
||||||
|
# 5 attempts in 5 seconds
|
||||||
|
window: 5 seconds
|
||||||
|
max: 5
|
||||||
|
|
||||||
|
|
||||||
oauth2:
|
oauth2:
|
||||||
token_lifetime:
|
token_lifetime:
|
||||||
|
@ -588,7 +593,7 @@ transcoding:
|
||||||
profile: 'default'
|
profile: 'default'
|
||||||
|
|
||||||
resolutions: # Only created if the original video has a higher resolution, uses more storage!
|
resolutions: # Only created if the original video has a higher resolution, uses more storage!
|
||||||
0p: false # audio-only (creates mp4 without video stream, always created when enabled)
|
0p: false # audio-only (creates mp4 without video stream)
|
||||||
144p: false
|
144p: false
|
||||||
240p: false
|
240p: false
|
||||||
360p: false
|
360p: false
|
||||||
|
@ -616,6 +621,11 @@ transcoding:
|
||||||
hls:
|
hls:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
# Store the audio stream in a separate file from the video
|
||||||
|
# This option adds the ability for the HLS player to propose the "Audio only" quality to users
|
||||||
|
# It also saves disk space by not duplicating the audio stream in each resolution file
|
||||||
|
split_audio_and_video: false
|
||||||
|
|
||||||
live:
|
live:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
@ -693,6 +703,7 @@ live:
|
||||||
profile: 'default'
|
profile: 'default'
|
||||||
|
|
||||||
resolutions:
|
resolutions:
|
||||||
|
0p: false # Audio only
|
||||||
144p: false
|
144p: false
|
||||||
240p: false
|
240p: false
|
||||||
360p: false
|
360p: false
|
||||||
|
|
|
@ -54,6 +54,11 @@ rates_limit:
|
||||||
# 500 attempts in 10 seconds (to not break crawlers)
|
# 500 attempts in 10 seconds (to not break crawlers)
|
||||||
window: 10 seconds
|
window: 10 seconds
|
||||||
max: 500
|
max: 500
|
||||||
|
download_generate_video: # A light FFmpeg process is used to generate videos (to merge audio and video streams for example)
|
||||||
|
# 5 attempts in 5 seconds
|
||||||
|
window: 5 seconds
|
||||||
|
max: 5
|
||||||
|
|
||||||
|
|
||||||
oauth2:
|
oauth2:
|
||||||
token_lifetime:
|
token_lifetime:
|
||||||
|
@ -598,7 +603,7 @@ transcoding:
|
||||||
profile: 'default'
|
profile: 'default'
|
||||||
|
|
||||||
resolutions: # Only created if the original video has a higher resolution, uses more storage!
|
resolutions: # Only created if the original video has a higher resolution, uses more storage!
|
||||||
0p: false # audio-only (creates mp4 without video stream, always created when enabled)
|
0p: false # audio-only (creates mp4 without video stream)
|
||||||
144p: false
|
144p: false
|
||||||
240p: false
|
240p: false
|
||||||
360p: false
|
360p: false
|
||||||
|
@ -626,6 +631,11 @@ transcoding:
|
||||||
hls:
|
hls:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
# Store the audio stream in a separate file from the video
|
||||||
|
# This option adds the ability for the HLS player to propose the "Audio only" quality to users
|
||||||
|
# It also saves disk space by not duplicating the audio stream in each resolution file
|
||||||
|
split_audio_and_video: false
|
||||||
|
|
||||||
live:
|
live:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
@ -703,6 +713,7 @@ live:
|
||||||
profile: 'default'
|
profile: 'default'
|
||||||
|
|
||||||
resolutions:
|
resolutions:
|
||||||
|
0p: false # Audio only
|
||||||
144p: false
|
144p: false
|
||||||
240p: false
|
240p: false
|
||||||
360p: false
|
360p: false
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { pick, promisify0 } from '@peertube/peertube-core-utils'
|
import { arrayify, pick, promisify0 } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import {
|
||||||
AvailableEncoders,
|
AvailableEncoders,
|
||||||
EncoderOptionsBuilder,
|
EncoderOptionsBuilder,
|
||||||
|
@ -8,6 +8,7 @@ import {
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { MutexInterface } from 'async-mutex'
|
import { MutexInterface } from 'async-mutex'
|
||||||
import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
|
import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
|
||||||
|
import { Readable } from 'node:stream'
|
||||||
|
|
||||||
export interface FFmpegCommandWrapperOptions {
|
export interface FFmpegCommandWrapperOptions {
|
||||||
availableEncoders?: AvailableEncoders
|
availableEncoders?: AvailableEncoders
|
||||||
|
@ -83,15 +84,19 @@ export class FFmpegCommandWrapper {
|
||||||
this.command = undefined
|
this.command = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
buildCommand (input: string, inputFileMutexReleaser?: MutexInterface.Releaser) {
|
buildCommand (inputs: (string | Readable)[] | string | Readable, inputFileMutexReleaser?: MutexInterface.Releaser) {
|
||||||
if (this.command) throw new Error('Command is already built')
|
if (this.command) throw new Error('Command is already built')
|
||||||
|
|
||||||
// We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
|
// We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
|
||||||
this.command = ffmpeg(input, {
|
this.command = ffmpeg({
|
||||||
niceness: this.niceness,
|
niceness: this.niceness,
|
||||||
cwd: this.tmpDirectory
|
cwd: this.tmpDirectory
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const input of arrayify(inputs)) {
|
||||||
|
this.command.input(input)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.threads > 0) {
|
if (this.threads > 0) {
|
||||||
// If we don't set any threads ffmpeg will chose automatically
|
// If we don't set any threads ffmpeg will chose automatically
|
||||||
this.command.outputOption('-threads ' + this.threads)
|
this.command.outputOption('-threads ' + this.threads)
|
||||||
|
@ -117,7 +122,10 @@ export class FFmpegCommandWrapper {
|
||||||
this.command.on('start', cmdline => { shellCommand = cmdline })
|
this.command.on('start', cmdline => { shellCommand = cmdline })
|
||||||
|
|
||||||
this.command.on('error', (err, stdout, stderr) => {
|
this.command.on('error', (err, stdout, stderr) => {
|
||||||
if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags })
|
if (silent !== true) this.logger.error('Error in ffmpeg.', { err, stdout, stderr, shellCommand, ...this.lTags })
|
||||||
|
|
||||||
|
err.stdout = stdout
|
||||||
|
err.stderr = stderr
|
||||||
|
|
||||||
if (this.onError) this.onError(err)
|
if (this.onError) this.onError(err)
|
||||||
|
|
||||||
|
|
|
@ -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 { FilterSpecification } from 'fluent-ffmpeg'
|
||||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||||
import { presetVOD } from './shared/presets.js'
|
|
||||||
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js'
|
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js'
|
||||||
|
import { presetVOD } from './shared/presets.js'
|
||||||
|
|
||||||
|
type BaseStudioOptions = {
|
||||||
|
videoInputPath: string
|
||||||
|
separatedAudioInputPath?: string
|
||||||
|
|
||||||
|
outputPath: string
|
||||||
|
|
||||||
|
// Will be released after the ffmpeg started
|
||||||
|
// To prevent a bug where the input file does not exist anymore when running ffmpeg
|
||||||
|
inputFileMutexReleaser?: MutexInterface.Releaser
|
||||||
|
}
|
||||||
|
|
||||||
export class FFmpegEdition {
|
export class FFmpegEdition {
|
||||||
private readonly commandWrapper: FFmpegCommandWrapper
|
private readonly commandWrapper: FFmpegCommandWrapper
|
||||||
|
@ -10,25 +22,27 @@ export class FFmpegEdition {
|
||||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
async cutVideo (options: {
|
async cutVideo (options: BaseStudioOptions & {
|
||||||
inputPath: string
|
|
||||||
outputPath: string
|
|
||||||
start?: number
|
start?: number
|
||||||
end?: number
|
end?: number
|
||||||
}) {
|
}) {
|
||||||
const { inputPath, outputPath } = options
|
const { videoInputPath, separatedAudioInputPath, outputPath, inputFileMutexReleaser } = options
|
||||||
|
|
||||||
const mainProbe = await ffprobePromise(inputPath)
|
const mainProbe = await ffprobePromise(videoInputPath)
|
||||||
const fps = await getVideoStreamFPS(inputPath, mainProbe)
|
const fps = await getVideoStreamFPS(videoInputPath, mainProbe)
|
||||||
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
|
const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, mainProbe)
|
||||||
|
|
||||||
const command = this.commandWrapper.buildCommand(inputPath)
|
const command = this.commandWrapper.buildCommand(this.buildInputs(options), inputFileMutexReleaser)
|
||||||
.output(outputPath)
|
.output(outputPath)
|
||||||
|
|
||||||
await presetVOD({
|
await presetVOD({
|
||||||
commandWrapper: this.commandWrapper,
|
commandWrapper: this.commandWrapper,
|
||||||
input: inputPath,
|
|
||||||
|
videoInputPath,
|
||||||
|
separatedAudioInputPath,
|
||||||
|
|
||||||
resolution,
|
resolution,
|
||||||
|
videoStreamOnly: false,
|
||||||
fps,
|
fps,
|
||||||
canCopyAudio: false,
|
canCopyAudio: false,
|
||||||
canCopyVideo: false
|
canCopyVideo: false
|
||||||
|
@ -45,10 +59,8 @@ export class FFmpegEdition {
|
||||||
await this.commandWrapper.runCommand()
|
await this.commandWrapper.runCommand()
|
||||||
}
|
}
|
||||||
|
|
||||||
async addWatermark (options: {
|
async addWatermark (options: BaseStudioOptions & {
|
||||||
inputPath: string
|
|
||||||
watermarkPath: string
|
watermarkPath: string
|
||||||
outputPath: string
|
|
||||||
|
|
||||||
videoFilters: {
|
videoFilters: {
|
||||||
watermarkSizeRatio: number
|
watermarkSizeRatio: number
|
||||||
|
@ -56,21 +68,23 @@ export class FFmpegEdition {
|
||||||
verticalMarginRatio: number
|
verticalMarginRatio: number
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const { watermarkPath, inputPath, outputPath, videoFilters } = options
|
const { watermarkPath, videoInputPath, separatedAudioInputPath, outputPath, videoFilters, inputFileMutexReleaser } = options
|
||||||
|
|
||||||
const videoProbe = await ffprobePromise(inputPath)
|
const videoProbe = await ffprobePromise(videoInputPath)
|
||||||
const fps = await getVideoStreamFPS(inputPath, videoProbe)
|
const fps = await getVideoStreamFPS(videoInputPath, videoProbe)
|
||||||
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
|
const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, videoProbe)
|
||||||
|
|
||||||
const command = this.commandWrapper.buildCommand(inputPath)
|
const command = this.commandWrapper.buildCommand([ ...this.buildInputs(options), watermarkPath ], inputFileMutexReleaser)
|
||||||
.output(outputPath)
|
.output(outputPath)
|
||||||
|
|
||||||
command.input(watermarkPath)
|
|
||||||
|
|
||||||
await presetVOD({
|
await presetVOD({
|
||||||
commandWrapper: this.commandWrapper,
|
commandWrapper: this.commandWrapper,
|
||||||
input: inputPath,
|
|
||||||
|
videoInputPath,
|
||||||
|
separatedAudioInputPath,
|
||||||
|
|
||||||
resolution,
|
resolution,
|
||||||
|
videoStreamOnly: false,
|
||||||
fps,
|
fps,
|
||||||
canCopyAudio: true,
|
canCopyAudio: true,
|
||||||
canCopyVideo: false
|
canCopyVideo: false
|
||||||
|
@ -103,27 +117,24 @@ export class FFmpegEdition {
|
||||||
await this.commandWrapper.runCommand()
|
await this.commandWrapper.runCommand()
|
||||||
}
|
}
|
||||||
|
|
||||||
async addIntroOutro (options: {
|
async addIntroOutro (options: BaseStudioOptions & {
|
||||||
inputPath: string
|
|
||||||
introOutroPath: string
|
introOutroPath: string
|
||||||
outputPath: string
|
|
||||||
type: 'intro' | 'outro'
|
type: 'intro' | 'outro'
|
||||||
}) {
|
}) {
|
||||||
const { introOutroPath, inputPath, outputPath, type } = options
|
const { introOutroPath, videoInputPath, separatedAudioInputPath, outputPath, type, inputFileMutexReleaser } = options
|
||||||
|
|
||||||
const mainProbe = await ffprobePromise(inputPath)
|
const mainProbe = await ffprobePromise(videoInputPath)
|
||||||
const fps = await getVideoStreamFPS(inputPath, mainProbe)
|
const fps = await getVideoStreamFPS(videoInputPath, mainProbe)
|
||||||
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
|
const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, mainProbe)
|
||||||
const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
|
const mainHasAudio = await hasAudioStream(separatedAudioInputPath || videoInputPath, mainProbe)
|
||||||
|
|
||||||
const introOutroProbe = await ffprobePromise(introOutroPath)
|
const introOutroProbe = await ffprobePromise(introOutroPath)
|
||||||
const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
|
const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
|
||||||
|
|
||||||
const command = this.commandWrapper.buildCommand(inputPath)
|
const command = this.commandWrapper.buildCommand([ ...this.buildInputs(options), introOutroPath ], inputFileMutexReleaser)
|
||||||
.output(outputPath)
|
.output(outputPath)
|
||||||
|
|
||||||
command.input(introOutroPath)
|
|
||||||
|
|
||||||
if (!introOutroHasAudio && mainHasAudio) {
|
if (!introOutroHasAudio && mainHasAudio) {
|
||||||
const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
|
const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
|
||||||
|
|
||||||
|
@ -134,8 +145,12 @@ export class FFmpegEdition {
|
||||||
|
|
||||||
await presetVOD({
|
await presetVOD({
|
||||||
commandWrapper: this.commandWrapper,
|
commandWrapper: this.commandWrapper,
|
||||||
input: inputPath,
|
|
||||||
|
videoInputPath,
|
||||||
|
separatedAudioInputPath,
|
||||||
|
|
||||||
resolution,
|
resolution,
|
||||||
|
videoStreamOnly: false,
|
||||||
fps,
|
fps,
|
||||||
canCopyAudio: false,
|
canCopyAudio: false,
|
||||||
canCopyVideo: false
|
canCopyVideo: false
|
||||||
|
@ -236,4 +251,11 @@ export class FFmpegEdition {
|
||||||
|
|
||||||
await this.commandWrapper.runCommand()
|
await this.commandWrapper.runCommand()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildInputs (options: {
|
||||||
|
videoInputPath: string
|
||||||
|
separatedAudioInputPath?: string
|
||||||
|
}) {
|
||||||
|
return [ options.videoInputPath, options.separatedAudioInputPath ].filter(i => !!i)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,12 @@
|
||||||
import { pick } from '@peertube/peertube-core-utils'
|
import { pick } from '@peertube/peertube-core-utils'
|
||||||
import { FfprobeData, FilterSpecification } from 'fluent-ffmpeg'
|
import { VideoResolution } from '@peertube/peertube-models'
|
||||||
|
import { FfmpegCommand, FfprobeData, FilterSpecification } from 'fluent-ffmpeg'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||||
import { StreamType, buildStreamSuffix, getScaleFilter } from './ffmpeg-utils.js'
|
import { StreamType, buildStreamSuffix, getScaleFilter } from './ffmpeg-utils.js'
|
||||||
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js'
|
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js'
|
||||||
|
|
||||||
export class FFmpegLive {
|
type LiveTranscodingOptions = {
|
||||||
private readonly commandWrapper: FFmpegCommandWrapper
|
|
||||||
|
|
||||||
constructor (options: FFmpegCommandWrapperOptions) {
|
|
||||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLiveTranscodingCommand (options: {
|
|
||||||
inputUrl: string
|
inputUrl: string
|
||||||
|
|
||||||
outPath: string
|
outPath: string
|
||||||
|
@ -27,41 +21,126 @@ export class FFmpegLive {
|
||||||
bitrate: number
|
bitrate: number
|
||||||
ratio: number
|
ratio: number
|
||||||
hasAudio: boolean
|
hasAudio: boolean
|
||||||
|
hasVideo: boolean
|
||||||
probe: FfprobeData
|
probe: FfprobeData
|
||||||
|
|
||||||
segmentListSize: number
|
segmentListSize: number
|
||||||
segmentDuration: number
|
segmentDuration: number
|
||||||
}) {
|
|
||||||
|
splitAudioAndVideo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FFmpegLive {
|
||||||
|
private readonly commandWrapper: FFmpegCommandWrapper
|
||||||
|
|
||||||
|
constructor (options: FFmpegCommandWrapperOptions) {
|
||||||
|
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLiveTranscodingCommand (options: LiveTranscodingOptions) {
|
||||||
|
this.commandWrapper.debugLog('Building live transcoding command', options)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
inputUrl,
|
inputUrl,
|
||||||
outPath,
|
outPath,
|
||||||
toTranscode,
|
toTranscode,
|
||||||
bitrate,
|
|
||||||
masterPlaylistName,
|
masterPlaylistName,
|
||||||
ratio,
|
|
||||||
hasAudio,
|
hasAudio,
|
||||||
probe
|
splitAudioAndVideo
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const command = this.commandWrapper.buildCommand(inputUrl)
|
const command = this.commandWrapper.buildCommand(inputUrl)
|
||||||
|
|
||||||
const varStreamMap: string[] = []
|
let varStreamMap: string[] = []
|
||||||
|
|
||||||
const complexFilter: FilterSpecification[] = [
|
|
||||||
{
|
|
||||||
inputs: '[v:0]',
|
|
||||||
filter: 'split',
|
|
||||||
options: toTranscode.length,
|
|
||||||
outputs: toTranscode.map(t => `vtemp${t.resolution}`)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
command.outputOption('-sc_threshold 0')
|
command.outputOption('-sc_threshold 0')
|
||||||
|
|
||||||
addDefaultEncoderGlobalParams(command)
|
addDefaultEncoderGlobalParams(command)
|
||||||
|
|
||||||
for (let i = 0; i < toTranscode.length; i++) {
|
// Audio input only or audio output only
|
||||||
const streamMap: string[] = []
|
if (this.isAudioInputOrOutputOnly(options)) {
|
||||||
const { resolution, fps } = toTranscode[i]
|
const result = await this.buildTranscodingStream({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
let complexFilter: FilterSpecification[] = [
|
||||||
|
{
|
||||||
|
inputs: '[v:0]',
|
||||||
|
filter: 'split',
|
||||||
|
options: toTranscodeWithoutAudioOnly.length,
|
||||||
|
outputs: toTranscodeWithoutAudioOnly.map(t => `vtemp${t.resolution}`)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
let alreadyProcessedAudio = false
|
||||||
|
|
||||||
|
for (let i = 0; i < toTranscodeWithoutAudioOnly.length; i++) {
|
||||||
|
let streamMap: string[] = []
|
||||||
|
|
||||||
|
const { resolution, fps } = toTranscodeWithoutAudioOnly[i]
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamMap.length !== 0) {
|
||||||
|
varStreamMap.push(streamMap.join(','))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
command.complexFilter(complexFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
|
||||||
|
|
||||||
|
command.outputOption('-var_stream_map', varStreamMap.join(' '))
|
||||||
|
|
||||||
|
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 = {
|
const baseEncoderBuilderParams = {
|
||||||
input: inputUrl,
|
input: inputUrl,
|
||||||
|
@ -76,29 +155,42 @@ export class FFmpegLive {
|
||||||
resolution,
|
resolution,
|
||||||
fps,
|
fps,
|
||||||
|
|
||||||
streamNum: i,
|
streamNum,
|
||||||
videoType: 'live' as 'live'
|
videoType: 'live' as 'live'
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
const streamMap: string[] = []
|
||||||
const streamType: StreamType = 'video'
|
const varStreamMap: string[] = []
|
||||||
|
const complexFilter: FilterSpecification[] = []
|
||||||
|
|
||||||
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
||||||
if (!builderResult) {
|
if (!builderResult) {
|
||||||
throw new Error('No available live video encoder found')
|
throw new Error(`No available live ${streamType} encoder found`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (streamType === 'audio') {
|
||||||
|
command.outputOption('-map a:0')
|
||||||
|
} else {
|
||||||
command.outputOption(`-map [vout${resolution}]`)
|
command.outputOption(`-map [vout${resolution}]`)
|
||||||
|
}
|
||||||
|
|
||||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
|
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum })
|
||||||
|
|
||||||
this.commandWrapper.debugLog(
|
this.commandWrapper.debugLog(
|
||||||
`Apply ffmpeg live video params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
|
`Apply ffmpeg live ${streamType} params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
|
||||||
{ builderResult, fps, toTranscode }
|
{ builderResult, fps, resolution }
|
||||||
)
|
)
|
||||||
|
|
||||||
command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
|
if (streamType === 'audio') {
|
||||||
applyEncoderOptions(command, builderResult.result)
|
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({
|
complexFilter.push({
|
||||||
inputs: `vtemp${resolution}`,
|
inputs: `vtemp${resolution}`,
|
||||||
|
@ -107,43 +199,23 @@ export class FFmpegLive {
|
||||||
outputs: `vout${resolution}`
|
outputs: `vout${resolution}`
|
||||||
})
|
})
|
||||||
|
|
||||||
streamMap.push(`v:${i}`)
|
if (splitAudioAndVideo) {
|
||||||
|
const suffix = hasAudio
|
||||||
|
? `,agroup:Audio`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
varStreamMap.push(`v:${streamNum}${suffix}`)
|
||||||
|
} else {
|
||||||
|
streamMap.push(`v:${streamNum}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAudio) {
|
|
||||||
const streamType: StreamType = 'audio'
|
|
||||||
|
|
||||||
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
|
||||||
if (!builderResult) {
|
|
||||||
throw new Error('No available live audio encoder found')
|
|
||||||
}
|
|
||||||
|
|
||||||
command.outputOption('-map a:0')
|
|
||||||
|
|
||||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
|
|
||||||
|
|
||||||
this.commandWrapper.debugLog(
|
|
||||||
`Apply ffmpeg live audio params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
|
|
||||||
{ builderResult, fps, resolution }
|
|
||||||
)
|
|
||||||
|
|
||||||
command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
|
|
||||||
applyEncoderOptions(command, builderResult.result)
|
applyEncoderOptions(command, builderResult.result)
|
||||||
|
|
||||||
streamMap.push(`a:${i}`)
|
return { varStreamMap, streamMap, complexFilter }
|
||||||
}
|
}
|
||||||
|
|
||||||
varStreamMap.push(streamMap.join(','))
|
// ---------------------------------------------------------------------------
|
||||||
}
|
|
||||||
|
|
||||||
command.complexFilter(complexFilter)
|
|
||||||
|
|
||||||
this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
|
|
||||||
|
|
||||||
command.outputOption('-var_stream_map', varStreamMap.join(' '))
|
|
||||||
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
|
|
||||||
getLiveMuxingCommand (options: {
|
getLiveMuxingCommand (options: {
|
||||||
inputUrl: string
|
inputUrl: string
|
||||||
|
@ -167,6 +239,8 @@ export class FFmpegLive {
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
private addDefaultLiveHLSParams (options: {
|
private addDefaultLiveHLSParams (options: {
|
||||||
outPath: string
|
outPath: string
|
||||||
masterPlaylistName: string
|
masterPlaylistName: string
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
import { pick } from '@peertube/peertube-core-utils'
|
import { pick } from '@peertube/peertube-core-utils'
|
||||||
import { VideoResolution } from '@peertube/peertube-models'
|
|
||||||
import { MutexInterface } from 'async-mutex'
|
import { MutexInterface } from 'async-mutex'
|
||||||
import { FfmpegCommand } from 'fluent-ffmpeg'
|
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||||
import { readFile, writeFile } from 'fs/promises'
|
import { readFile, writeFile } from 'fs/promises'
|
||||||
import { dirname } from 'path'
|
import { dirname } from 'path'
|
||||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||||
import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js'
|
import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js'
|
||||||
import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js'
|
import { presetCopy, presetVOD } from './shared/presets.js'
|
||||||
|
|
||||||
export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio'
|
export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio'
|
||||||
|
|
||||||
export interface BaseTranscodeVODOptions {
|
export interface BaseTranscodeVODOptions {
|
||||||
type: TranscodeVODOptionsType
|
type: TranscodeVODOptionsType
|
||||||
|
|
||||||
inputPath: string
|
videoInputPath: string
|
||||||
|
separatedAudioInputPath?: string
|
||||||
|
|
||||||
outputPath: string
|
outputPath: string
|
||||||
|
|
||||||
// Will be released after the ffmpeg started
|
// Will be released after the ffmpeg started
|
||||||
|
@ -28,6 +29,7 @@ export interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
|
||||||
type: 'hls'
|
type: 'hls'
|
||||||
|
|
||||||
copyCodecs: boolean
|
copyCodecs: boolean
|
||||||
|
separatedAudio: boolean
|
||||||
|
|
||||||
hlsPlaylist: {
|
hlsPlaylist: {
|
||||||
videoFilename: string
|
videoFilename: string
|
||||||
|
@ -83,12 +85,14 @@ export class FFmpegVOD {
|
||||||
'hls': this.buildHLSVODCommand.bind(this),
|
'hls': this.buildHLSVODCommand.bind(this),
|
||||||
'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
|
'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
|
||||||
'merge-audio': this.buildAudioMergeCommand.bind(this),
|
'merge-audio': this.buildAudioMergeCommand.bind(this),
|
||||||
'video': this.buildWebVideoCommand.bind(this)
|
'video': this.buildVODCommand.bind(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.commandWrapper.debugLog('Will run transcode.', { options })
|
this.commandWrapper.debugLog('Will run transcode.', { options })
|
||||||
|
|
||||||
this.commandWrapper.buildCommand(options.inputPath, options.inputFileMutexReleaser)
|
const inputPaths = [ options.videoInputPath, options.separatedAudioInputPath ].filter(e => !!e)
|
||||||
|
|
||||||
|
this.commandWrapper.buildCommand(inputPaths, options.inputFileMutexReleaser)
|
||||||
.output(options.outputPath)
|
.output(options.outputPath)
|
||||||
|
|
||||||
await builders[options.type](options)
|
await builders[options.type](options)
|
||||||
|
@ -104,19 +108,26 @@ export class FFmpegVOD {
|
||||||
return this.ended
|
return this.ended
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildWebVideoCommand (options: TranscodeVODOptions & { canCopyAudio?: boolean, canCopyVideo?: boolean }) {
|
private async buildVODCommand (options: TranscodeVODOptions & {
|
||||||
const { resolution, fps, inputPath, canCopyAudio = true, canCopyVideo = true } = options
|
videoStreamOnly?: boolean
|
||||||
|
canCopyAudio?: boolean
|
||||||
if (resolution === VideoResolution.H_NOVIDEO) {
|
canCopyVideo?: boolean
|
||||||
presetOnlyAudio(this.commandWrapper)
|
}) {
|
||||||
return
|
const {
|
||||||
}
|
resolution,
|
||||||
|
fps,
|
||||||
|
videoInputPath,
|
||||||
|
separatedAudioInputPath,
|
||||||
|
videoStreamOnly = false,
|
||||||
|
canCopyAudio = true,
|
||||||
|
canCopyVideo = true
|
||||||
|
} = options
|
||||||
|
|
||||||
let scaleFilterValue: string
|
let scaleFilterValue: string
|
||||||
|
|
||||||
if (resolution !== undefined) {
|
if (resolution) {
|
||||||
const probe = await ffprobePromise(inputPath)
|
const probe = await ffprobePromise(videoInputPath)
|
||||||
const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe)
|
const videoStreamInfo = await getVideoStreamDimensionsInfo(videoInputPath, probe)
|
||||||
|
|
||||||
scaleFilterValue = videoStreamInfo?.isPortraitMode === true
|
scaleFilterValue = videoStreamInfo?.isPortraitMode === true
|
||||||
? `w=${resolution}:h=-2`
|
? `w=${resolution}:h=-2`
|
||||||
|
@ -127,7 +138,11 @@ export class FFmpegVOD {
|
||||||
commandWrapper: this.commandWrapper,
|
commandWrapper: this.commandWrapper,
|
||||||
|
|
||||||
resolution,
|
resolution,
|
||||||
input: inputPath,
|
videoStreamOnly,
|
||||||
|
|
||||||
|
videoInputPath,
|
||||||
|
separatedAudioInputPath,
|
||||||
|
|
||||||
canCopyAudio,
|
canCopyAudio,
|
||||||
canCopyVideo,
|
canCopyVideo,
|
||||||
fps,
|
fps,
|
||||||
|
@ -157,9 +172,10 @@ export class FFmpegVOD {
|
||||||
...pick(options, [ 'resolution' ]),
|
...pick(options, [ 'resolution' ]),
|
||||||
|
|
||||||
commandWrapper: this.commandWrapper,
|
commandWrapper: this.commandWrapper,
|
||||||
input: options.audioPath,
|
videoInputPath: options.audioPath,
|
||||||
canCopyAudio: true,
|
canCopyAudio: true,
|
||||||
canCopyVideo: true,
|
canCopyVideo: true,
|
||||||
|
videoStreamOnly: false,
|
||||||
fps: options.fps,
|
fps: options.fps,
|
||||||
scaleFilterValue: this.getMergeAudioScaleFilterValue()
|
scaleFilterValue: this.getMergeAudioScaleFilterValue()
|
||||||
})
|
})
|
||||||
|
@ -186,13 +202,16 @@ export class FFmpegVOD {
|
||||||
const videoPath = this.getHLSVideoPath(options)
|
const videoPath = this.getHLSVideoPath(options)
|
||||||
|
|
||||||
if (options.copyCodecs) {
|
if (options.copyCodecs) {
|
||||||
presetCopy(this.commandWrapper)
|
presetCopy(this.commandWrapper, {
|
||||||
} else if (options.resolution === VideoResolution.H_NOVIDEO) {
|
withAudio: !options.separatedAudio || !options.resolution,
|
||||||
presetOnlyAudio(this.commandWrapper)
|
withVideo: !options.separatedAudio || !!options.resolution
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// If we cannot copy codecs, we do not copy them at all to prevent issues like audio desync
|
await this.buildVODCommand({
|
||||||
// See for example https://github.com/Chocobozzz/PeerTube/issues/6438
|
...options,
|
||||||
await this.buildWebVideoCommand({ ...options, canCopyAudio: false, canCopyVideo: false })
|
|
||||||
|
videoStreamOnly: options.separatedAudio && !!options.resolution
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.addCommonHLSVODCommandOptions(command, videoPath)
|
this.addCommonHLSVODCommandOptions(command, videoPath)
|
||||||
|
|
|
@ -174,6 +174,12 @@ async function getVideoStream (path: string, existingProbe?: FfprobeData) {
|
||||||
return metadata.streams.find(s => s.codec_type === 'video')
|
return metadata.streams.find(s => s.codec_type === 'video')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hasVideoStream (path: string, existingProbe?: FfprobeData) {
|
||||||
|
const videoStream = await getVideoStream(path, existingProbe)
|
||||||
|
|
||||||
|
return !!videoStream
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Chapters
|
// Chapters
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
@ -209,5 +215,6 @@ export {
|
||||||
isAudioFile,
|
isAudioFile,
|
||||||
ffprobePromise,
|
ffprobePromise,
|
||||||
getVideoStreamBitrate,
|
getVideoStreamBitrate,
|
||||||
hasAudioStream
|
hasAudioStream,
|
||||||
|
hasVideoStream
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export * from './ffmpeg-command-wrapper.js'
|
export * from './ffmpeg-command-wrapper.js'
|
||||||
|
export * from './ffmpeg-container.js'
|
||||||
export * from './ffmpeg-default-transcoding-profile.js'
|
export * from './ffmpeg-default-transcoding-profile.js'
|
||||||
export * from './ffmpeg-edition.js'
|
export * from './ffmpeg-edition.js'
|
||||||
export * from './ffmpeg-images.js'
|
export * from './ffmpeg-images.js'
|
||||||
|
|
|
@ -7,7 +7,8 @@ import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOpt
|
||||||
export async function presetVOD (options: {
|
export async function presetVOD (options: {
|
||||||
commandWrapper: FFmpegCommandWrapper
|
commandWrapper: FFmpegCommandWrapper
|
||||||
|
|
||||||
input: string
|
videoInputPath: string
|
||||||
|
separatedAudioInputPath?: string
|
||||||
|
|
||||||
canCopyAudio: boolean
|
canCopyAudio: boolean
|
||||||
canCopyVideo: boolean
|
canCopyVideo: boolean
|
||||||
|
@ -15,9 +16,16 @@ export async function presetVOD (options: {
|
||||||
resolution: number
|
resolution: number
|
||||||
fps: number
|
fps: number
|
||||||
|
|
||||||
|
videoStreamOnly: boolean
|
||||||
|
|
||||||
scaleFilterValue?: string
|
scaleFilterValue?: string
|
||||||
}) {
|
}) {
|
||||||
const { commandWrapper, input, resolution, fps, scaleFilterValue } = options
|
const { commandWrapper, videoInputPath, separatedAudioInputPath, resolution, fps, videoStreamOnly, scaleFilterValue } = options
|
||||||
|
|
||||||
|
if (videoStreamOnly && !resolution) {
|
||||||
|
throw new Error('Cannot generate video stream only without valid resolution')
|
||||||
|
}
|
||||||
|
|
||||||
const command = commandWrapper.getCommand()
|
const command = commandWrapper.getCommand()
|
||||||
|
|
||||||
command.format('mp4')
|
command.format('mp4')
|
||||||
|
@ -25,27 +33,40 @@ export async function presetVOD (options: {
|
||||||
|
|
||||||
addDefaultEncoderGlobalParams(command)
|
addDefaultEncoderGlobalParams(command)
|
||||||
|
|
||||||
const probe = await ffprobePromise(input)
|
const videoProbe = await ffprobePromise(videoInputPath)
|
||||||
|
const audioProbe = separatedAudioInputPath
|
||||||
|
? await ffprobePromise(separatedAudioInputPath)
|
||||||
|
: videoProbe
|
||||||
|
|
||||||
// Audio encoder
|
// Audio encoder
|
||||||
const bitrate = await getVideoStreamBitrate(input, probe)
|
const bitrate = await getVideoStreamBitrate(videoInputPath, videoProbe)
|
||||||
const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
|
const videoStreamDimensions = await getVideoStreamDimensionsInfo(videoInputPath, videoProbe)
|
||||||
|
|
||||||
let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
|
let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
|
||||||
|
|
||||||
if (!await hasAudioStream(input, probe)) {
|
if (videoStreamOnly || !await hasAudioStream(separatedAudioInputPath || videoInputPath, audioProbe)) {
|
||||||
command.noAudio()
|
command.noAudio()
|
||||||
streamsToProcess = [ 'video' ]
|
streamsToProcess = [ 'video' ]
|
||||||
|
} else if (!resolution) {
|
||||||
|
command.noVideo()
|
||||||
|
streamsToProcess = [ 'audio' ]
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const streamType of streamsToProcess) {
|
for (const streamType of streamsToProcess) {
|
||||||
|
const input = streamType === 'video'
|
||||||
|
? videoInputPath
|
||||||
|
: separatedAudioInputPath || videoInputPath
|
||||||
|
|
||||||
const builderResult = await commandWrapper.getEncoderBuilderResult({
|
const builderResult = await commandWrapper.getEncoderBuilderResult({
|
||||||
...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]),
|
...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]),
|
||||||
|
|
||||||
input,
|
input,
|
||||||
|
inputProbe: streamType === 'video'
|
||||||
|
? videoProbe
|
||||||
|
: audioProbe,
|
||||||
|
|
||||||
inputBitrate: bitrate,
|
inputBitrate: bitrate,
|
||||||
inputRatio: videoStreamDimensions?.ratio || 0,
|
inputRatio: videoStreamDimensions?.ratio || 0,
|
||||||
inputProbe: probe,
|
|
||||||
|
|
||||||
resolution,
|
resolution,
|
||||||
fps,
|
fps,
|
||||||
|
@ -79,16 +100,17 @@ export async function presetVOD (options: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function presetCopy (commandWrapper: FFmpegCommandWrapper) {
|
export function presetCopy (commandWrapper: FFmpegCommandWrapper, options: {
|
||||||
commandWrapper.getCommand()
|
withAudio?: boolean // default true
|
||||||
.format('mp4')
|
withVideo?: boolean // default true
|
||||||
.videoCodec('copy')
|
} = {}) {
|
||||||
.audioCodec('copy')
|
const command = commandWrapper.getCommand()
|
||||||
}
|
|
||||||
|
|
||||||
export function presetOnlyAudio (commandWrapper: FFmpegCommandWrapper) {
|
command.format('mp4')
|
||||||
commandWrapper.getCommand()
|
|
||||||
.format('mp4')
|
if (options.withAudio === false) command.noAudio()
|
||||||
.audioCodec('copy')
|
else command.audioCodec('copy')
|
||||||
.noVideo()
|
|
||||||
|
if (options.withVideo === false) command.noVideo()
|
||||||
|
else command.videoCodec('copy')
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,18 @@ export interface ActivityIconObject {
|
||||||
height: number | null
|
height: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ActivityVideoUrlObjectAttachment = {
|
||||||
|
type: 'PropertyValue'
|
||||||
|
name: 'ffprobe_codec_type'
|
||||||
|
value: 'video' | 'audio'
|
||||||
|
} | {
|
||||||
|
type: 'PropertyValue'
|
||||||
|
name: 'peertube_format_flag'
|
||||||
|
value: 'web-video' | 'fragmented'
|
||||||
|
}
|
||||||
|
|
||||||
export type ActivityVideoUrlObject = {
|
export type ActivityVideoUrlObject = {
|
||||||
type: 'Link'
|
type: 'Link'
|
||||||
mediaType: 'video/mp4' | 'video/webm' | 'video/ogg' | 'audio/mp4'
|
mediaType: 'video/mp4' | 'video/webm' | 'video/ogg' | 'audio/mp4'
|
||||||
|
@ -22,8 +34,12 @@ export type ActivityVideoUrlObject = {
|
||||||
width: number | null
|
width: number | null
|
||||||
size: number
|
size: number
|
||||||
fps: number
|
fps: number
|
||||||
|
|
||||||
|
attachment: ActivityVideoUrlObjectAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type ActivityPlaylistSegmentHashesObject = {
|
export type ActivityPlaylistSegmentHashesObject = {
|
||||||
type: 'Link'
|
type: 'Link'
|
||||||
name: 'sha256'
|
name: 'sha256'
|
||||||
|
|
|
@ -106,6 +106,7 @@ export const serverFilterHookObject = {
|
||||||
|
|
||||||
// Filter result used to check if video/torrent download is allowed
|
// Filter result used to check if video/torrent download is allowed
|
||||||
'filter:api.download.video.allowed.result': true,
|
'filter:api.download.video.allowed.result': true,
|
||||||
|
'filter:api.download.generated-video.allowed.result': true,
|
||||||
'filter:api.download.torrent.allowed.result': true,
|
'filter:api.download.torrent.allowed.result': true,
|
||||||
|
|
||||||
// Filter result to check if the embed is allowed for a particular request
|
// Filter result to check if the embed is allowed for a particular request
|
||||||
|
|
|
@ -16,6 +16,7 @@ export type RunnerJobPayload =
|
||||||
export interface RunnerJobVODWebVideoTranscodingPayload {
|
export interface RunnerJobVODWebVideoTranscodingPayload {
|
||||||
input: {
|
input: {
|
||||||
videoFileUrl: string
|
videoFileUrl: string
|
||||||
|
separatedAudioFileUrl: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
output: {
|
output: {
|
||||||
|
@ -27,11 +28,13 @@ export interface RunnerJobVODWebVideoTranscodingPayload {
|
||||||
export interface RunnerJobVODHLSTranscodingPayload {
|
export interface RunnerJobVODHLSTranscodingPayload {
|
||||||
input: {
|
input: {
|
||||||
videoFileUrl: string
|
videoFileUrl: string
|
||||||
|
separatedAudioFileUrl: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
output: {
|
output: {
|
||||||
resolution: number
|
resolution: number
|
||||||
fps: number
|
fps: number
|
||||||
|
separatedAudio: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,6 +53,7 @@ export interface RunnerJobVODAudioMergeTranscodingPayload {
|
||||||
export interface RunnerJobStudioTranscodingPayload {
|
export interface RunnerJobStudioTranscodingPayload {
|
||||||
input: {
|
input: {
|
||||||
videoFileUrl: string
|
videoFileUrl: string
|
||||||
|
separatedAudioFileUrl: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks: VideoStudioTaskPayload[]
|
tasks: VideoStudioTaskPayload[]
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
|
||||||
import { BroadcastMessageLevel } from './broadcast-message-level.type.js'
|
import { BroadcastMessageLevel } from './broadcast-message-level.type.js'
|
||||||
|
|
||||||
export type ConfigResolutions = {
|
export type ConfigResolutions = {
|
||||||
|
'0p': boolean
|
||||||
'144p': boolean
|
'144p': boolean
|
||||||
'240p': boolean
|
'240p': boolean
|
||||||
'360p': boolean
|
'360p': boolean
|
||||||
|
@ -133,7 +134,7 @@ export interface CustomConfig {
|
||||||
|
|
||||||
profile: string
|
profile: string
|
||||||
|
|
||||||
resolutions: ConfigResolutions & { '0p': boolean }
|
resolutions: ConfigResolutions
|
||||||
|
|
||||||
alwaysTranscodeOriginalResolution: boolean
|
alwaysTranscodeOriginalResolution: boolean
|
||||||
|
|
||||||
|
@ -143,6 +144,7 @@ export interface CustomConfig {
|
||||||
|
|
||||||
hls: {
|
hls: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
splitAudioAndVideo: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -147,6 +147,7 @@ export type ManageVideoTorrentPayload =
|
||||||
|
|
||||||
interface BaseTranscodingPayload {
|
interface BaseTranscodingPayload {
|
||||||
videoUUID: string
|
videoUUID: string
|
||||||
|
hasChildren?: boolean
|
||||||
isNewVideo?: boolean
|
isNewVideo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,6 +157,8 @@ export interface HLSTranscodingPayload extends BaseTranscodingPayload {
|
||||||
fps: number
|
fps: number
|
||||||
copyCodecs: boolean
|
copyCodecs: boolean
|
||||||
|
|
||||||
|
separatedAudio: boolean
|
||||||
|
|
||||||
deleteWebVideoFiles: boolean
|
deleteWebVideoFiles: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,16 +173,12 @@ export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload {
|
||||||
|
|
||||||
resolution: number
|
resolution: number
|
||||||
fps: number
|
fps: number
|
||||||
|
|
||||||
hasChildren: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptimizeTranscodingPayload extends BaseTranscodingPayload {
|
export interface OptimizeTranscodingPayload extends BaseTranscodingPayload {
|
||||||
type: 'optimize-to-web-video'
|
type: 'optimize-to-web-video'
|
||||||
|
|
||||||
quickTranscode: boolean
|
quickTranscode: boolean
|
||||||
|
|
||||||
hasChildren: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VideoTranscodingPayload =
|
export type VideoTranscodingPayload =
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
export * from './video-file-metadata.model.js'
|
export * from './video-file-metadata.model.js'
|
||||||
export * from './video-file.model.js'
|
export * from './video-file.model.js'
|
||||||
export * from './video-resolution.enum.js'
|
export * from './video-resolution.enum.js'
|
||||||
|
export * from './video-file-format-flag.enum.js'
|
||||||
|
export * from './video-file-stream.enum.js'
|
||||||
|
|
|
@ -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
|
metadataUrl?: string
|
||||||
|
|
||||||
magnetUri: string | null
|
magnetUri: string | null
|
||||||
|
|
||||||
|
hasAudio: boolean
|
||||||
|
hasVideo: boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-comm
|
||||||
|
|
||||||
export class ConfigCommand extends AbstractCommand {
|
export class ConfigCommand extends AbstractCommand {
|
||||||
|
|
||||||
static getCustomConfigResolutions (enabled: boolean, with0p = false) {
|
static getConfigResolutions (enabled: boolean, with0p = false) {
|
||||||
return {
|
return {
|
||||||
'0p': enabled && with0p,
|
'0p': enabled && with0p,
|
||||||
'144p': enabled,
|
'144p': enabled,
|
||||||
|
@ -19,6 +19,20 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getCustomConfigResolutions (enabled: number[]) {
|
||||||
|
return {
|
||||||
|
'0p': enabled.includes(0),
|
||||||
|
'144p': enabled.includes(144),
|
||||||
|
'240p': enabled.includes(240),
|
||||||
|
'360p': enabled.includes(360),
|
||||||
|
'480p': enabled.includes(480),
|
||||||
|
'720p': enabled.includes(720),
|
||||||
|
'1080p': enabled.includes(1080),
|
||||||
|
'1440p': enabled.includes(1440),
|
||||||
|
'2160p': enabled.includes(2160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static getEmailOverrideConfig (emailPort: number) {
|
static getEmailOverrideConfig (emailPort: number) {
|
||||||
|
@ -211,19 +225,27 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
|
|
||||||
enableLive (options: {
|
enableLive (options: {
|
||||||
allowReplay?: boolean
|
allowReplay?: boolean
|
||||||
|
resolutions?: 'min' | 'max' | number[] // default 'min'
|
||||||
transcoding?: boolean
|
transcoding?: boolean
|
||||||
resolutions?: 'min' | 'max' // Default max
|
maxDuration?: number
|
||||||
|
alwaysTranscodeOriginalResolution?: boolean
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { allowReplay, transcoding, resolutions = 'max' } = options
|
const { allowReplay, transcoding, maxDuration, resolutions = 'min', alwaysTranscodeOriginalResolution } = options
|
||||||
|
|
||||||
return this.updateExistingConfig({
|
return this.updateExistingConfig({
|
||||||
newConfig: {
|
newConfig: {
|
||||||
live: {
|
live: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowReplay: allowReplay ?? true,
|
allowReplay,
|
||||||
|
maxDuration,
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: transcoding ?? true,
|
enabled: transcoding,
|
||||||
resolutions: ConfigCommand.getCustomConfigResolutions(resolutions === 'max')
|
|
||||||
|
alwaysTranscodeOriginalResolution,
|
||||||
|
|
||||||
|
resolutions: Array.isArray(resolutions)
|
||||||
|
? ConfigCommand.getCustomConfigResolutions(resolutions)
|
||||||
|
: ConfigCommand.getConfigResolutions(resolutions === 'max')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -246,10 +268,14 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
enableTranscoding (options: {
|
enableTranscoding (options: {
|
||||||
webVideo?: boolean // default true
|
webVideo?: boolean // default true
|
||||||
hls?: boolean // default true
|
hls?: boolean // default true
|
||||||
with0p?: boolean // default false
|
|
||||||
keepOriginal?: boolean // default false
|
keepOriginal?: boolean // default false
|
||||||
|
splitAudioAndVideo?: boolean // default false
|
||||||
|
|
||||||
|
resolutions?: 'min' | 'max' | number[] // default 'max'
|
||||||
|
|
||||||
|
with0p?: boolean // default false
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { webVideo = true, hls = true, with0p = false, keepOriginal = false } = options
|
const { resolutions = 'max', webVideo = true, hls = true, with0p = false, keepOriginal = false, splitAudioAndVideo = false } = options
|
||||||
|
|
||||||
return this.updateExistingConfig({
|
return this.updateExistingConfig({
|
||||||
newConfig: {
|
newConfig: {
|
||||||
|
@ -262,25 +288,39 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
allowAudioFiles: true,
|
allowAudioFiles: true,
|
||||||
allowAdditionalExtensions: true,
|
allowAdditionalExtensions: true,
|
||||||
|
|
||||||
resolutions: ConfigCommand.getCustomConfigResolutions(true, with0p),
|
resolutions: Array.isArray(resolutions)
|
||||||
|
? ConfigCommand.getCustomConfigResolutions(resolutions)
|
||||||
|
: ConfigCommand.getConfigResolutions(resolutions === 'max', with0p),
|
||||||
|
|
||||||
webVideos: {
|
webVideos: {
|
||||||
enabled: webVideo
|
enabled: webVideo
|
||||||
},
|
},
|
||||||
hls: {
|
hls: {
|
||||||
enabled: hls
|
enabled: hls,
|
||||||
|
splitAudioAndVideo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTranscodingConcurrency (concurrency: number) {
|
||||||
|
return this.updateExistingConfig({
|
||||||
|
newConfig: {
|
||||||
|
transcoding: {
|
||||||
|
concurrency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
enableMinimumTranscoding (options: {
|
enableMinimumTranscoding (options: {
|
||||||
webVideo?: boolean // default true
|
webVideo?: boolean // default true
|
||||||
hls?: boolean // default true
|
hls?: boolean // default true
|
||||||
|
splitAudioAndVideo?: boolean // default false
|
||||||
keepOriginal?: boolean // default false
|
keepOriginal?: boolean // default false
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { webVideo = true, hls = true, keepOriginal = false } = options
|
const { webVideo = true, hls = true, keepOriginal = false, splitAudioAndVideo = false } = options
|
||||||
|
|
||||||
return this.updateExistingConfig({
|
return this.updateExistingConfig({
|
||||||
newConfig: {
|
newConfig: {
|
||||||
|
@ -294,7 +334,7 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
allowAdditionalExtensions: true,
|
allowAdditionalExtensions: true,
|
||||||
|
|
||||||
resolutions: {
|
resolutions: {
|
||||||
...ConfigCommand.getCustomConfigResolutions(false),
|
...ConfigCommand.getConfigResolutions(false),
|
||||||
|
|
||||||
'240p': true
|
'240p': true
|
||||||
},
|
},
|
||||||
|
@ -303,7 +343,8 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
enabled: webVideo
|
enabled: webVideo
|
||||||
},
|
},
|
||||||
hls: {
|
hls: {
|
||||||
enabled: hls
|
enabled: hls,
|
||||||
|
splitAudioAndVideo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { waitJobs } from './jobs.js'
|
import { waitJobs } from './jobs.js'
|
||||||
import { PeerTubeServer } from './server.js'
|
import { PeerTubeServer } from './server.js'
|
||||||
|
|
||||||
async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
|
export async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
server1.follows.follow({ hosts: [ server2.url ] }),
|
server1.follows.follow({ hosts: [ server2.url ] }),
|
||||||
server2.follows.follow({ hosts: [ server1.url ] })
|
server2.follows.follow({ hosts: [ server1.url ] })
|
||||||
|
@ -9,12 +9,18 @@ async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
|
||||||
|
|
||||||
// Wait request propagation
|
// Wait request propagation
|
||||||
await waitJobs([ server1, server2 ])
|
await waitJobs([ server1, server2 ])
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export function followAll (servers: PeerTubeServer[]) {
|
||||||
|
const p: Promise<void>[] = []
|
||||||
|
|
||||||
export {
|
for (const server of servers) {
|
||||||
doubleFollow
|
for (const remoteServer of servers) {
|
||||||
|
if (server === remoteServer) continue
|
||||||
|
|
||||||
|
p.push(doubleFollow(server, remoteServer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(p)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,7 @@ async function waitJobs (
|
||||||
|
|
||||||
// Check if each server has pending request
|
// Check if each server has pending request
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
if (process.env.DEBUG) console.log('Checking ' + server.url)
|
if (process.env.DEBUG) console.log(`${new Date().toISOString()} - Checking ${server.url}`)
|
||||||
|
|
||||||
for (const state of states) {
|
for (const state of states) {
|
||||||
|
|
||||||
|
@ -45,7 +45,7 @@ async function waitJobs (
|
||||||
pendingRequests = true
|
pendingRequests = true
|
||||||
|
|
||||||
if (process.env.DEBUG) {
|
if (process.env.DEBUG) {
|
||||||
console.log(jobs)
|
console.log(`${new Date().toISOString()}`, jobs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -59,7 +59,7 @@ async function waitJobs (
|
||||||
pendingRequests = true
|
pendingRequests = true
|
||||||
|
|
||||||
if (process.env.DEBUG) {
|
if (process.env.DEBUG) {
|
||||||
console.log('AP messages waiting: ' + obj.activityPubMessagesWaiting)
|
console.log(`${new Date().toISOString()} - AP messages waiting: ${obj.activityPubMessagesWaiting}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -73,7 +73,7 @@ async function waitJobs (
|
||||||
pendingRequests = true
|
pendingRequests = true
|
||||||
|
|
||||||
if (process.env.DEBUG) {
|
if (process.env.DEBUG) {
|
||||||
console.log(job)
|
console.log(`${new Date().toISOString()}`, job)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { PeerTubeServer } from '../server/server.js'
|
||||||
export async function setDefaultAccountAvatar (serversArg: PeerTubeServer | PeerTubeServer[], token?: string) {
|
export async function setDefaultAccountAvatar (serversArg: PeerTubeServer | PeerTubeServer[], token?: string) {
|
||||||
const servers = arrayify(serversArg)
|
const servers = arrayify(serversArg)
|
||||||
|
|
||||||
for (const server of servers) {
|
return Promise.all(
|
||||||
await server.users.updateMyAvatar({ fixture: 'avatar.png', token })
|
servers.map(s => s.users.updateMyAvatar({ fixture: 'avatar.png', token }))
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,22 +2,18 @@ import { arrayify } from '@peertube/peertube-core-utils'
|
||||||
import { PeerTubeServer } from '../server/server.js'
|
import { PeerTubeServer } from '../server/server.js'
|
||||||
|
|
||||||
export function setDefaultVideoChannel (servers: PeerTubeServer[]) {
|
export function setDefaultVideoChannel (servers: PeerTubeServer[]) {
|
||||||
const tasks: Promise<any>[] = []
|
return Promise.all(
|
||||||
|
servers.map(s => {
|
||||||
for (const server of servers) {
|
return s.users.getMyInfo()
|
||||||
const p = server.users.getMyInfo()
|
.then(user => { s.store.channel = user.videoChannels[0] })
|
||||||
.then(user => { server.store.channel = user.videoChannels[0] })
|
})
|
||||||
|
)
|
||||||
tasks.push(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.all(tasks)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setDefaultChannelAvatar (serversArg: PeerTubeServer | PeerTubeServer[], channelName: string = 'root_channel') {
|
export async function setDefaultChannelAvatar (serversArg: PeerTubeServer | PeerTubeServer[], channelName: string = 'root_channel') {
|
||||||
const servers = arrayify(serversArg)
|
const servers = arrayify(serversArg)
|
||||||
|
|
||||||
for (const server of servers) {
|
return Promise.all(
|
||||||
await server.channels.updateImage({ channelName, fixture: 'avatar.png', type: 'avatar' })
|
servers.map(s => s.channels.updateImage({ channelName, fixture: 'avatar.png', type: 'avatar' }))
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -167,6 +167,7 @@ export class LiveCommand extends AbstractCommand {
|
||||||
async runAndTestStreamError (options: OverrideCommandOptions & {
|
async runAndTestStreamError (options: OverrideCommandOptions & {
|
||||||
videoId: number | string
|
videoId: number | string
|
||||||
shouldHaveError: boolean
|
shouldHaveError: boolean
|
||||||
|
fixtureName?: string
|
||||||
}) {
|
}) {
|
||||||
const command = await this.sendRTMPStreamInVideo(options)
|
const command = await this.sendRTMPStreamInVideo(options)
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
|
||||||
import truncate from 'lodash-es/truncate.js'
|
import truncate from 'lodash-es/truncate.js'
|
||||||
import { PeerTubeServer } from '../server/server.js'
|
import { PeerTubeServer } from '../server/server.js'
|
||||||
|
|
||||||
function sendRTMPStream (options: {
|
export function sendRTMPStream (options: {
|
||||||
rtmpBaseUrl: string
|
rtmpBaseUrl: string
|
||||||
streamKey: string
|
streamKey: string
|
||||||
fixtureName?: string // default video_short.mp4
|
fixtureName?: string // default video_short.mp4
|
||||||
|
@ -49,7 +49,7 @@ function sendRTMPStream (options: {
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) {
|
export function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) {
|
||||||
return new Promise<void>((res, rej) => {
|
return new Promise<void>((res, rej) => {
|
||||||
command.on('error', err => {
|
command.on('error', err => {
|
||||||
return rej(err)
|
return rej(err)
|
||||||
|
@ -61,7 +61,7 @@ function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) {
|
export async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) {
|
||||||
let error: Error
|
let error: Error
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -76,31 +76,39 @@ async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: b
|
||||||
if (!shouldHaveError && error) throw error
|
if (!shouldHaveError && error) throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopFfmpeg (command: FfmpegCommand) {
|
export async function stopFfmpeg (command: FfmpegCommand) {
|
||||||
command.kill('SIGINT')
|
command.kill('SIGINT')
|
||||||
|
|
||||||
await wait(500)
|
await wait(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) {
|
export async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) {
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
await server.live.waitUntilPublished({ videoId })
|
await server.live.waitUntilPublished({ videoId })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitUntilLiveWaitingOnAllServers (servers: PeerTubeServer[], videoId: string) {
|
export async function waitUntilLiveWaitingOnAllServers (servers: PeerTubeServer[], videoId: string) {
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
await server.live.waitUntilWaiting({ videoId })
|
await server.live.waitUntilWaiting({ videoId })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitUntilLiveReplacedByReplayOnAllServers (servers: PeerTubeServer[], videoId: string) {
|
export async function waitUntilLiveReplacedByReplayOnAllServers (servers: PeerTubeServer[], videoId: string) {
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
await server.live.waitUntilReplacedByReplay({ videoId })
|
await server.live.waitUntilReplacedByReplay({ videoId })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findExternalSavedVideo (server: PeerTubeServer, liveDetails: VideoDetails) {
|
export async function findExternalSavedVideo (server: PeerTubeServer, liveVideoUUID: string) {
|
||||||
|
let liveDetails: VideoDetails
|
||||||
|
|
||||||
|
try {
|
||||||
|
liveDetails = await server.videos.getWithToken({ id: liveVideoUUID })
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
const include = VideoInclude.BLACKLISTED
|
const include = VideoInclude.BLACKLISTED
|
||||||
const privacyOneOf = [ VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.PUBLIC, VideoPrivacy.UNLISTED ]
|
const privacyOneOf = [ VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.PUBLIC, VideoPrivacy.UNLISTED ]
|
||||||
|
|
||||||
|
@ -114,16 +122,3 @@ async function findExternalSavedVideo (server: PeerTubeServer, liveDetails: Vide
|
||||||
|
|
||||||
return data.find(v => v.name === toFind)
|
return data.find(v => v.name === toFind)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
|
||||||
sendRTMPStream,
|
|
||||||
waitFfmpegUntilError,
|
|
||||||
testFfmpegStreamError,
|
|
||||||
stopFfmpeg,
|
|
||||||
|
|
||||||
waitUntilLivePublishedOnAllServers,
|
|
||||||
waitUntilLiveReplacedByReplayOnAllServers,
|
|
||||||
waitUntilLiveWaitingOnAllServers,
|
|
||||||
|
|
||||||
findExternalSavedVideo
|
|
||||||
}
|
|
||||||
|
|
|
@ -341,6 +341,14 @@ export class VideosCommand extends AbstractCommand {
|
||||||
return data.find(v => v.name === options.name)
|
return data.find(v => v.name === options.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findFull (options: OverrideCommandOptions & {
|
||||||
|
name: string
|
||||||
|
}) {
|
||||||
|
const { uuid } = await this.find(options)
|
||||||
|
|
||||||
|
return this.get({ id: uuid })
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
update (options: OverrideCommandOptions & {
|
update (options: OverrideCommandOptions & {
|
||||||
|
@ -662,4 +670,25 @@ export class VideosCommand extends AbstractCommand {
|
||||||
endVideoResumableUpload (options: Parameters<AbstractCommand['endResumableUpload']>[0]) {
|
endVideoResumableUpload (options: Parameters<AbstractCommand['endResumableUpload']>[0]) {
|
||||||
return super.endResumableUpload(options)
|
return super.endResumableUpload(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
generateDownload (options: OverrideCommandOptions & {
|
||||||
|
videoId: number | string
|
||||||
|
videoFileIds: number[]
|
||||||
|
query?: Record<string, string>
|
||||||
|
}) {
|
||||||
|
const { videoFileIds, videoId, query = {} } = options
|
||||||
|
const path = '/download/videos/generate/' + videoId
|
||||||
|
|
||||||
|
return this.getRequestBody<Buffer>({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
path,
|
||||||
|
query: { videoFileIds, ...query },
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
implicitToken: true,
|
||||||
|
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
|
@ -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 './custom-pages.js'
|
||||||
import './debug.js'
|
import './debug.js'
|
||||||
import './follows.js'
|
import './follows.js'
|
||||||
|
import './generate-download.js'
|
||||||
import './jobs.js'
|
import './jobs.js'
|
||||||
import './live.js'
|
import './live.js'
|
||||||
import './logs.js'
|
import './logs.js'
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
import { basename } from 'path'
|
|
||||||
import {
|
import {
|
||||||
HttpStatusCode,
|
HttpStatusCode,
|
||||||
HttpStatusCodeType,
|
HttpStatusCodeType,
|
||||||
|
@ -12,7 +11,6 @@ import {
|
||||||
VideoPrivacy,
|
VideoPrivacy,
|
||||||
VideoStudioTaskIntro
|
VideoStudioTaskIntro
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
|
|
||||||
import {
|
import {
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
createSingleServer,
|
createSingleServer,
|
||||||
|
@ -25,6 +23,8 @@ import {
|
||||||
VideoStudioCommand,
|
VideoStudioCommand,
|
||||||
waitJobs
|
waitJobs
|
||||||
} from '@peertube/peertube-server-commands'
|
} from '@peertube/peertube-server-commands'
|
||||||
|
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
|
||||||
|
import { basename } from 'path'
|
||||||
|
|
||||||
const badUUID = '910ec12a-d9e6-458b-a274-0abb655f9464'
|
const badUUID = '910ec12a-d9e6-458b-a274-0abb655f9464'
|
||||||
|
|
||||||
|
@ -66,7 +66,7 @@ describe('Test managing runners', function () {
|
||||||
registrationToken = data[0].registrationToken
|
registrationToken = data[0].registrationToken
|
||||||
registrationTokenId = data[0].id
|
registrationTokenId = data[0].id
|
||||||
|
|
||||||
await server.config.enableTranscoding({ hls: true, webVideo: true })
|
await server.config.enableTranscoding({ hls: true, webVideo: true, resolutions: 'min' })
|
||||||
await server.config.enableStudio()
|
await server.config.enableStudio()
|
||||||
await server.config.enableRemoteTranscoding()
|
await server.config.enableRemoteTranscoding()
|
||||||
await server.config.enableRemoteStudio()
|
await server.config.enableRemoteStudio()
|
||||||
|
@ -452,7 +452,7 @@ describe('Test managing runners', function () {
|
||||||
const { uuid } = await server.videos.quickUpload({ name: 'video studio' })
|
const { uuid } = await server.videos.quickUpload({ name: 'video studio' })
|
||||||
videoStudioUUID = uuid
|
videoStudioUUID = uuid
|
||||||
|
|
||||||
await server.config.enableTranscoding({ hls: true, webVideo: true })
|
await server.config.enableTranscoding({ hls: true, webVideo: true, resolutions: 'min' })
|
||||||
await server.config.enableStudio()
|
await server.config.enableStudio()
|
||||||
|
|
||||||
await server.videoStudio.createEditionTasks({
|
await server.videoStudio.createEditionTasks({
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { getAllFiles } from '@peertube/peertube-core-utils'
|
import { getAllFiles, getHLS } from '@peertube/peertube-core-utils'
|
||||||
import { HttpStatusCode, UserRole, VideoDetails, VideoPrivacy } from '@peertube/peertube-models'
|
import { HttpStatusCode, UserRole, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
|
||||||
import {
|
import {
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
createMultipleServers,
|
createMultipleServers,
|
||||||
|
@ -73,9 +73,14 @@ describe('Test videos files', function () {
|
||||||
let remoteHLSFileId: number
|
let remoteHLSFileId: number
|
||||||
let remoteWebVideoFileId: number
|
let remoteWebVideoFileId: number
|
||||||
|
|
||||||
|
let splittedHLSId: string
|
||||||
|
let hlsWithAudioId: string
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
this.timeout(300_000)
|
this.timeout(300_000)
|
||||||
|
|
||||||
|
const resolutions = [ VideoResolution.H_NOVIDEO, VideoResolution.H_144P, VideoResolution.H_240P ]
|
||||||
|
|
||||||
{
|
{
|
||||||
const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' })
|
const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' })
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
@ -87,7 +92,7 @@ describe('Test videos files', function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
await servers[0].config.enableTranscoding({ hls: true, webVideo: true })
|
await servers[0].config.enableTranscoding({ hls: true, webVideo: true, resolutions })
|
||||||
|
|
||||||
{
|
{
|
||||||
const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' })
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' })
|
||||||
|
@ -103,22 +108,43 @@ describe('Test videos files', function () {
|
||||||
const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' })
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' })
|
||||||
validId2 = uuid
|
validId2 = uuid
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
await servers[0].config.enableTranscoding({ hls: true, webVideo: false })
|
await servers[0].config.enableTranscoding({ hls: true, webVideo: false, resolutions })
|
||||||
const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' })
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' })
|
||||||
hlsId = uuid
|
hlsId = uuid
|
||||||
}
|
|
||||||
|
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
await servers[0].config.enableTranscoding({ webVideo: true, hls: false })
|
await servers[0].config.enableTranscoding({ webVideo: true, hls: false, resolutions })
|
||||||
const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' })
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' })
|
||||||
webVideoId = uuid
|
webVideoId = uuid
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await servers[0].config.enableTranscoding({ webVideo: true, hls: true, splitAudioAndVideo: true, resolutions })
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'splitted-audio-video' })
|
||||||
|
splittedHLSId = uuid
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await servers[0].config.enableTranscoding({
|
||||||
|
webVideo: true,
|
||||||
|
hls: true,
|
||||||
|
splitAudioAndVideo: false,
|
||||||
|
resolutions
|
||||||
|
})
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' })
|
||||||
|
hlsWithAudioId = uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
@ -168,9 +194,6 @@ describe('Test videos files', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should not delete files if the files are not available', async function () {
|
it('Should not delete files if the files are not available', async function () {
|
||||||
await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
|
||||||
await servers[0].videos.removeAllWebVideoFiles({ videoId: webVideoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
|
||||||
|
|
||||||
await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
})
|
})
|
||||||
|
@ -187,6 +210,40 @@ describe('Test videos files', function () {
|
||||||
await servers[0].videos.removeHLSPlaylist({ videoId: validId1 })
|
await servers[0].videos.removeHLSPlaylist({ videoId: validId1 })
|
||||||
await servers[0].videos.removeAllWebVideoFiles({ videoId: validId2 })
|
await servers[0].videos.removeAllWebVideoFiles({ videoId: validId2 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should not delete audio if audio and video is splitted', async function () {
|
||||||
|
const video = await servers[0].videos.get({ id: splittedHLSId })
|
||||||
|
const audio = getHLS(video).files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO)
|
||||||
|
|
||||||
|
await servers[0].videos.removeHLSFile({ videoId: splittedHLSId, fileId: audio.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should be able to delete audio if audio is the latest resolution', async function () {
|
||||||
|
const video = await servers[0].videos.get({ id: splittedHLSId })
|
||||||
|
const audio = getHLS(video).files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO)
|
||||||
|
|
||||||
|
for (const file of getHLS(video).files) {
|
||||||
|
if (file.resolution.id === VideoResolution.H_NOVIDEO) continue
|
||||||
|
|
||||||
|
await servers[0].videos.removeHLSFile({ videoId: splittedHLSId, fileId: file.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
await servers[0].videos.removeHLSFile({ videoId: splittedHLSId, fileId: audio.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should be able to delete audio of web video', async function () {
|
||||||
|
const video = await servers[0].videos.get({ id: splittedHLSId })
|
||||||
|
const audio = video.files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO)
|
||||||
|
|
||||||
|
await servers[0].videos.removeWebVideoFile({ videoId: splittedHLSId, fileId: audio.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should be able to delete audio if audio and video are not splitted', async function () {
|
||||||
|
const video = await servers[0].videos.get({ id: hlsWithAudioId })
|
||||||
|
const audio = getHLS(video).files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO)
|
||||||
|
|
||||||
|
await servers[0].videos.removeHLSFile({ videoId: hlsWithAudioId, fileId: audio.id })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
|
|
|
@ -204,7 +204,7 @@ describe('Test video sources API validator', function () {
|
||||||
await makeRawRequest({ url: source.fileDownloadUrl, token: user3, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url: source.fileDownloadUrl, token: user3, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should succeed with a valid header token', async function () {
|
it('Should succeed with a valid query token', async function () {
|
||||||
await makeRawRequest({ url: source.fileDownloadUrl, query: { videoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url: source.fileDownloadUrl, query: { videoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import './live-constraints.js'
|
import './live-constraints.js'
|
||||||
import './live-fast-restream.js'
|
import './live-fast-restream.js'
|
||||||
import './live-socket-messages.js'
|
|
||||||
import './live-privacy-update.js'
|
|
||||||
import './live-permanent.js'
|
import './live-permanent.js'
|
||||||
|
import './live-privacy-update.js'
|
||||||
import './live-rtmps.js'
|
import './live-rtmps.js'
|
||||||
import './live-save-replay.js'
|
import './live-save-replay.js'
|
||||||
|
import './live-socket-messages.js'
|
||||||
|
import './live-audio-or-video-only.js'
|
||||||
import './live.js'
|
import './live.js'
|
||||||
|
|
|
@ -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 */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { wait } from '@peertube/peertube-core-utils'
|
import { wait } from '@peertube/peertube-core-utils'
|
||||||
import { LiveVideoError, UserVideoQuota, VideoPrivacy } from '@peertube/peertube-models'
|
import { LiveVideoError, UserVideoQuota, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
|
||||||
import {
|
import {
|
||||||
PeerTubeServer,
|
PeerTubeServer,
|
||||||
cleanupTests, createMultipleServers,
|
cleanupTests, createMultipleServers,
|
||||||
|
@ -38,14 +38,14 @@ describe('Test live constraints', function () {
|
||||||
return uuid
|
return uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkSaveReplay (videoId: string, resolutions = [ 720 ]) {
|
async function checkSaveReplay (videoId: string, savedResolutions?: number[]) {
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
const video = await server.videos.get({ id: videoId })
|
const video = await server.videos.get({ id: videoId })
|
||||||
expect(video.isLive).to.be.false
|
expect(video.isLive).to.be.false
|
||||||
expect(video.duration).to.be.greaterThan(0)
|
expect(video.duration).to.be.greaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
await checkLiveCleanup({ server: servers[0], permanent: false, videoUUID: videoId, savedResolutions: resolutions })
|
await checkLiveCleanup({ server: servers[0], permanent: false, videoUUID: videoId, savedResolutions })
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateQuota (options: { total: number, daily: number }) {
|
function updateQuota (options: { total: number, daily: number }) {
|
||||||
|
@ -100,7 +100,7 @@ describe('Test live constraints', function () {
|
||||||
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
|
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
||||||
await checkSaveReplay(userVideoLiveoId)
|
await checkSaveReplay(userVideoLiveoId, [ VideoResolution.H_720P ])
|
||||||
|
|
||||||
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
|
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
|
||||||
expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
|
expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
|
||||||
|
@ -136,7 +136,7 @@ describe('Test live constraints', function () {
|
||||||
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
|
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
||||||
await checkSaveReplay(userVideoLiveoId)
|
await checkSaveReplay(userVideoLiveoId, [ VideoResolution.H_720P ])
|
||||||
|
|
||||||
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
|
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
|
||||||
expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
|
expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
|
||||||
|
@ -223,7 +223,7 @@ describe('Test live constraints', function () {
|
||||||
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
|
await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId)
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
||||||
await checkSaveReplay(userVideoLiveoId, [ 720, 240, 144 ])
|
await checkSaveReplay(userVideoLiveoId, [ 720, 240, 144, 0 ])
|
||||||
|
|
||||||
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
|
const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
|
||||||
expect(session.error).to.equal(LiveVideoError.DURATION_EXCEEDED)
|
expect(session.error).to.equal(LiveVideoError.DURATION_EXCEEDED)
|
||||||
|
|
|
@ -61,7 +61,7 @@ describe('Permanent live', function () {
|
||||||
maxDuration: -1,
|
maxDuration: -1,
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
resolutions: ConfigCommand.getCustomConfigResolutions(true)
|
resolutions: ConfigCommand.getConfigResolutions(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -152,7 +152,7 @@ describe('Permanent live', function () {
|
||||||
maxDuration: -1,
|
maxDuration: -1,
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
resolutions: ConfigCommand.getCustomConfigResolutions(false)
|
resolutions: ConfigCommand.getConfigResolutions(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -167,8 +167,8 @@ describe('Permanent live', function () {
|
||||||
await checkVideoState(videoUUID, VideoState.PUBLISHED)
|
await checkVideoState(videoUUID, VideoState.PUBLISHED)
|
||||||
|
|
||||||
const count = await servers[0].live.countPlaylists({ videoUUID })
|
const count = await servers[0].live.countPlaylists({ videoUUID })
|
||||||
// master playlist and 720p playlist
|
// master playlist, 720p playlist and audio only playlist
|
||||||
expect(count).to.equal(2)
|
expect(count).to.equal(3)
|
||||||
|
|
||||||
await stopFfmpeg(ffmpegCommand)
|
await stopFfmpeg(ffmpegCommand)
|
||||||
})
|
})
|
||||||
|
|
|
@ -155,7 +155,7 @@ describe('Save replay setting', function () {
|
||||||
maxDuration: -1,
|
maxDuration: -1,
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
resolutions: ConfigCommand.getCustomConfigResolutions(true)
|
resolutions: ConfigCommand.getConfigResolutions(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -422,14 +422,12 @@ describe('Save replay setting', function () {
|
||||||
it('Should correctly have saved the live', async function () {
|
it('Should correctly have saved the live', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
const liveDetails = await servers[0].videos.get({ id: liveVideoUUID })
|
|
||||||
|
|
||||||
await stopFfmpeg(ffmpegCommand)
|
await stopFfmpeg(ffmpegCommand)
|
||||||
|
|
||||||
await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID)
|
await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID)
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
||||||
const video = await findExternalSavedVideo(servers[0], liveDetails)
|
const video = await findExternalSavedVideo(servers[0], liveVideoUUID)
|
||||||
expect(video).to.exist
|
expect(video).to.exist
|
||||||
|
|
||||||
await servers[0].videos.get({ id: video.uuid })
|
await servers[0].videos.get({ id: video.uuid })
|
||||||
|
@ -508,14 +506,12 @@ describe('Save replay setting', function () {
|
||||||
it('Should correctly have saved the live and federated it after the streaming', async function () {
|
it('Should correctly have saved the live and federated it after the streaming', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
const liveDetails = await servers[0].videos.get({ id: liveVideoUUID })
|
|
||||||
|
|
||||||
await stopFfmpeg(ffmpegCommand)
|
await stopFfmpeg(ffmpegCommand)
|
||||||
|
|
||||||
await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID)
|
await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID)
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
||||||
const video = await findExternalSavedVideo(servers[0], liveDetails)
|
const video = await findExternalSavedVideo(servers[0], liveVideoUUID)
|
||||||
expect(video).to.exist
|
expect(video).to.exist
|
||||||
|
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
|
@ -569,7 +565,7 @@ describe('Save replay setting', function () {
|
||||||
replaySettings: { privacy: VideoPrivacy.PUBLIC }
|
replaySettings: { privacy: VideoPrivacy.PUBLIC }
|
||||||
})
|
})
|
||||||
|
|
||||||
const replay = await findExternalSavedVideo(servers[0], liveDetails)
|
const replay = await findExternalSavedVideo(servers[0], liveDetails.uuid)
|
||||||
expect(replay).to.exist
|
expect(replay).to.exist
|
||||||
|
|
||||||
for (const videoId of [ liveVideoUUID, replay.uuid ]) {
|
for (const videoId of [ liveVideoUUID, replay.uuid ]) {
|
||||||
|
@ -591,7 +587,7 @@ describe('Save replay setting', function () {
|
||||||
replaySettings: { privacy: VideoPrivacy.PUBLIC }
|
replaySettings: { privacy: VideoPrivacy.PUBLIC }
|
||||||
})
|
})
|
||||||
|
|
||||||
const replay = await findExternalSavedVideo(servers[0], liveDetails)
|
const replay = await findExternalSavedVideo(servers[0], liveDetails.uuid)
|
||||||
expect(replay).to.not.exist
|
expect(replay).to.not.exist
|
||||||
|
|
||||||
await checkVideosExist(liveVideoUUID, 1, HttpStatusCode.NOT_FOUND_404)
|
await checkVideosExist(liveVideoUUID, 1, HttpStatusCode.NOT_FOUND_404)
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { expect } from 'chai'
|
|
||||||
import { basename, join } from 'path'
|
|
||||||
import { getAllFiles, wait } from '@peertube/peertube-core-utils'
|
import { getAllFiles, wait } from '@peertube/peertube-core-utils'
|
||||||
import { ffprobePromise, getVideoStream } from '@peertube/peertube-ffmpeg'
|
import { ffprobePromise } from '@peertube/peertube-ffmpeg'
|
||||||
import {
|
import {
|
||||||
HttpStatusCode,
|
HttpStatusCode,
|
||||||
LiveVideo,
|
LiveVideo,
|
||||||
|
@ -12,6 +10,7 @@ import {
|
||||||
VideoCommentPolicy,
|
VideoCommentPolicy,
|
||||||
VideoDetails,
|
VideoDetails,
|
||||||
VideoPrivacy,
|
VideoPrivacy,
|
||||||
|
VideoResolution,
|
||||||
VideoState,
|
VideoState,
|
||||||
VideoStreamingPlaylistType
|
VideoStreamingPlaylistType
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
|
@ -35,6 +34,8 @@ import {
|
||||||
import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js'
|
import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js'
|
||||||
import { testLiveVideoResolutions } from '@tests/shared/live.js'
|
import { testLiveVideoResolutions } from '@tests/shared/live.js'
|
||||||
import { SQLCommand } from '@tests/shared/sql-command.js'
|
import { SQLCommand } from '@tests/shared/sql-command.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import { basename, join } from 'path'
|
||||||
|
|
||||||
describe('Test live', function () {
|
describe('Test live', function () {
|
||||||
let servers: PeerTubeServer[] = []
|
let servers: PeerTubeServer[] = []
|
||||||
|
@ -399,38 +400,22 @@ describe('Test live', function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateConf (resolutions: number[]) {
|
function updateConf (resolutions: number[]) {
|
||||||
return servers[0].config.updateExistingConfig({
|
return servers[0].config.enableLive({
|
||||||
newConfig: {
|
|
||||||
live: {
|
|
||||||
enabled: true,
|
|
||||||
allowReplay: true,
|
allowReplay: true,
|
||||||
maxDuration: -1,
|
resolutions,
|
||||||
transcoding: {
|
transcoding: true,
|
||||||
enabled: true,
|
maxDuration: -1
|
||||||
resolutions: {
|
|
||||||
'144p': resolutions.includes(144),
|
|
||||||
'240p': resolutions.includes(240),
|
|
||||||
'360p': resolutions.includes(360),
|
|
||||||
'480p': resolutions.includes(480),
|
|
||||||
'720p': resolutions.includes(720),
|
|
||||||
'1080p': resolutions.includes(1080),
|
|
||||||
'2160p': resolutions.includes(2160)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
await updateConf([])
|
|
||||||
|
|
||||||
sqlCommandServer1 = new SQLCommand(servers[0])
|
sqlCommandServer1 = new SQLCommand(servers[0])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should enable transcoding without additional resolutions', async function () {
|
it('Should enable transcoding without additional resolutions', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
|
await updateConf([])
|
||||||
liveVideoId = await createLiveWrapper(false)
|
liveVideoId = await createLiveWrapper(false)
|
||||||
|
|
||||||
const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId })
|
const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId })
|
||||||
|
@ -449,18 +434,6 @@ describe('Test live', function () {
|
||||||
await stopFfmpeg(ffmpegCommand)
|
await stopFfmpeg(ffmpegCommand)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should transcode audio only RTMP stream', async function () {
|
|
||||||
this.timeout(120000)
|
|
||||||
|
|
||||||
liveVideoId = await createLiveWrapper(false)
|
|
||||||
|
|
||||||
const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_short_no_audio.mp4' })
|
|
||||||
await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
|
|
||||||
await waitJobs(servers)
|
|
||||||
|
|
||||||
await stopFfmpeg(ffmpegCommand)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should enable transcoding with some resolutions', async function () {
|
it('Should enable transcoding with some resolutions', async function () {
|
||||||
this.timeout(240000)
|
this.timeout(240000)
|
||||||
|
|
||||||
|
@ -541,15 +514,17 @@ describe('Test live', function () {
|
||||||
await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
|
await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
|
||||||
|
|
||||||
const maxBitrateLimits = {
|
const maxBitrateLimits = {
|
||||||
720: 6500 * 1000, // 60FPS
|
720: 6350 * 1000, // 60FPS
|
||||||
360: 1250 * 1000,
|
360: 1100 * 1000,
|
||||||
240: 700 * 1000
|
240: 600 * 1000,
|
||||||
|
0: 170 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
const minBitrateLimits = {
|
const minBitrateLimits = {
|
||||||
720: 4800 * 1000,
|
720: 4650 * 1000,
|
||||||
360: 1000 * 1000,
|
360: 850 * 1000,
|
||||||
240: 550 * 1000
|
240: 400 * 1000,
|
||||||
|
0: 100 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
|
@ -568,9 +543,10 @@ describe('Test live', function () {
|
||||||
expect(basename(hlsPlaylist.playlistUrl)).to.not.equal('master.m3u8')
|
expect(basename(hlsPlaylist.playlistUrl)).to.not.equal('master.m3u8')
|
||||||
expect(basename(hlsPlaylist.segmentsSha256Url)).to.not.equal('segments-sha256.json')
|
expect(basename(hlsPlaylist.segmentsSha256Url)).to.not.equal('segments-sha256.json')
|
||||||
|
|
||||||
expect(hlsPlaylist.files).to.have.lengthOf(resolutions.length)
|
const resolutionsAndAudio = [ VideoResolution.H_NOVIDEO, ...resolutions ]
|
||||||
|
expect(hlsPlaylist.files).to.have.lengthOf(resolutionsAndAudio.length)
|
||||||
|
|
||||||
for (const resolution of resolutions) {
|
for (const resolution of resolutionsAndAudio) {
|
||||||
const file = hlsPlaylist.files.find(f => f.resolution.id === resolution)
|
const file = hlsPlaylist.files.find(f => f.resolution.id === resolution)
|
||||||
|
|
||||||
expect(file).to.exist
|
expect(file).to.exist
|
||||||
|
@ -578,6 +554,8 @@ describe('Test live', function () {
|
||||||
|
|
||||||
if (resolution >= 720) {
|
if (resolution >= 720) {
|
||||||
expect(file.fps).to.be.approximately(60, 10)
|
expect(file.fps).to.be.approximately(60, 10)
|
||||||
|
} else if (resolution === VideoResolution.H_NOVIDEO) {
|
||||||
|
expect(file.fps).to.equal(0)
|
||||||
} else {
|
} else {
|
||||||
expect(file.fps).to.be.approximately(30, 3)
|
expect(file.fps).to.be.approximately(30, 3)
|
||||||
}
|
}
|
||||||
|
@ -588,10 +566,9 @@ describe('Test live', function () {
|
||||||
const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename))
|
const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename))
|
||||||
|
|
||||||
const probe = await ffprobePromise(segmentPath)
|
const probe = await ffprobePromise(segmentPath)
|
||||||
const videoStream = await getVideoStream(segmentPath, probe)
|
|
||||||
|
|
||||||
expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height])
|
expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[resolution])
|
||||||
expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height])
|
expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[resolution])
|
||||||
|
|
||||||
await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
@ -640,11 +617,12 @@ describe('Test live', function () {
|
||||||
const video = await servers[0].videos.get({ id: liveVideoId })
|
const video = await servers[0].videos.get({ id: liveVideoId })
|
||||||
const hlsFiles = video.streamingPlaylists[0].files
|
const hlsFiles = video.streamingPlaylists[0].files
|
||||||
|
|
||||||
expect(video.files).to.have.lengthOf(0)
|
const resolutionsWithAudio = [ VideoResolution.H_NOVIDEO, ...resolutions ]
|
||||||
expect(hlsFiles).to.have.lengthOf(resolutions.length)
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-array-sort-compare
|
expect(video.files).to.have.lengthOf(0)
|
||||||
expect(getAllFiles(video).map(f => f.resolution.id).sort()).to.deep.equal(resolutions)
|
expect(hlsFiles).to.have.lengthOf(resolutionsWithAudio.length)
|
||||||
|
|
||||||
|
expect(getAllFiles(video).map(f => f.resolution.id)).to.have.members(resolutionsWithAudio)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should only keep the original resolution if all resolutions are disabled', async function () {
|
it('Should only keep the original resolution if all resolutions are disabled', async function () {
|
||||||
|
@ -677,9 +655,9 @@ describe('Test live', function () {
|
||||||
const hlsFiles = video.streamingPlaylists[0].files
|
const hlsFiles = video.streamingPlaylists[0].files
|
||||||
|
|
||||||
expect(video.files).to.have.lengthOf(0)
|
expect(video.files).to.have.lengthOf(0)
|
||||||
expect(hlsFiles).to.have.lengthOf(1)
|
|
||||||
|
|
||||||
expect(hlsFiles[0].resolution.id).to.equal(720)
|
expect(hlsFiles).to.have.lengthOf(2)
|
||||||
|
expect(hlsFiles.map(f => f.resolution.id)).to.have.members([ VideoResolution.H_720P, VideoResolution.H_NOVIDEO ])
|
||||||
})
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
|
|
|
@ -451,14 +451,12 @@ describe('Test user notifications', function () {
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
await servers[1].live.waitUntilPublished({ videoId: shortUUID })
|
await servers[1].live.waitUntilPublished({ videoId: shortUUID })
|
||||||
|
|
||||||
const liveDetails = await servers[1].videos.get({ id: shortUUID })
|
|
||||||
|
|
||||||
await stopFfmpeg(ffmpegCommand)
|
await stopFfmpeg(ffmpegCommand)
|
||||||
|
|
||||||
await servers[1].live.waitUntilWaiting({ videoId: shortUUID })
|
await servers[1].live.waitUntilWaiting({ videoId: shortUUID })
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
||||||
const video = await findExternalSavedVideo(servers[1], liveDetails)
|
const video = await findExternalSavedVideo(servers[1], shortUUID)
|
||||||
expect(video).to.exist
|
expect(video).to.exist
|
||||||
|
|
||||||
await checkMyVideoIsPublished({ ...baseParams, videoName: video.name, shortUUID: video.shortUUID, checkType: 'presence' })
|
await checkMyVideoIsPublished({ ...baseParams, videoName: video.name, shortUUID: video.shortUUID, checkType: 'presence' })
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { expect } from 'chai'
|
import { HttpStatusCode, LiveVideoCreate, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
|
||||||
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
import { HttpStatusCode, LiveVideoCreate, VideoPrivacy } from '@peertube/peertube-models'
|
|
||||||
import {
|
import {
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
createMultipleServers,
|
createMultipleServers,
|
||||||
|
@ -23,6 +22,7 @@ import { expectStartWith } from '@tests/shared/checks.js'
|
||||||
import { testLiveVideoResolutions } from '@tests/shared/live.js'
|
import { testLiveVideoResolutions } from '@tests/shared/live.js'
|
||||||
import { MockObjectStorageProxy } from '@tests/shared/mock-servers/mock-object-storage.js'
|
import { MockObjectStorageProxy } from '@tests/shared/mock-servers/mock-object-storage.js'
|
||||||
import { SQLCommand } from '@tests/shared/sql-command.js'
|
import { SQLCommand } from '@tests/shared/sql-command.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
async function createLive (server: PeerTubeServer, permanent: boolean) {
|
async function createLive (server: PeerTubeServer, permanent: boolean) {
|
||||||
const attributes: LiveVideoCreate = {
|
const attributes: LiveVideoCreate = {
|
||||||
|
@ -118,7 +118,7 @@ describe('Object storage for lives', function () {
|
||||||
let videoUUID: string
|
let videoUUID: string
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
await servers[0].config.enableLive({ transcoding: false })
|
await servers[0].config.enableLive({ transcoding: false, allowReplay: true })
|
||||||
|
|
||||||
videoUUID = await createLive(servers[0], false)
|
videoUUID = await createLive(servers[0], false)
|
||||||
})
|
})
|
||||||
|
@ -157,10 +157,10 @@ describe('Object storage for lives', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('With live transcoding', function () {
|
describe('With live transcoding', function () {
|
||||||
const resolutions = [ 720, 480, 360, 240, 144 ]
|
const resolutions = [ VideoResolution.H_720P, VideoResolution.H_240P ]
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
await servers[0].config.enableLive({ transcoding: true })
|
await servers[0].config.enableLive({ transcoding: true, resolutions })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Normal replay', function () {
|
describe('Normal replay', function () {
|
||||||
|
@ -195,7 +195,8 @@ describe('Object storage for lives', function () {
|
||||||
await waitUntilLiveReplacedByReplayOnAllServers(servers, videoUUIDNonPermanent)
|
await waitUntilLiveReplacedByReplayOnAllServers(servers, videoUUIDNonPermanent)
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
||||||
await checkFilesExist({ servers, videoUUID: videoUUIDNonPermanent, numberOfFiles: 5, objectStorage })
|
const numberOfFiles = resolutions.length + 1 // +1 for the HLS audio file
|
||||||
|
await checkFilesExist({ servers, videoUUID: videoUUIDNonPermanent, numberOfFiles, objectStorage })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have cleaned up live files from object storage', async function () {
|
it('Should have cleaned up live files from object storage', async function () {
|
||||||
|
@ -235,10 +236,10 @@ describe('Object storage for lives', function () {
|
||||||
await waitUntilLiveWaitingOnAllServers(servers, videoUUIDPermanent)
|
await waitUntilLiveWaitingOnAllServers(servers, videoUUIDPermanent)
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
||||||
const videoLiveDetails = await servers[0].videos.get({ id: videoUUIDPermanent })
|
const replay = await findExternalSavedVideo(servers[0], videoUUIDPermanent)
|
||||||
const replay = await findExternalSavedVideo(servers[0], videoLiveDetails)
|
|
||||||
|
|
||||||
await checkFilesExist({ servers, videoUUID: replay.uuid, numberOfFiles: 5, objectStorage })
|
const numberOfFiles = resolutions.length + 1 // +1 for the HLS audio file
|
||||||
|
await checkFilesExist({ servers, videoUUID: replay.uuid, numberOfFiles, objectStorage })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have cleaned up live files from object storage', async function () {
|
it('Should have cleaned up live files from object storage', async function () {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { getAllFiles, getHLS } from '@peertube/peertube-core-utils'
|
import { getAllFiles, getHLS } from '@peertube/peertube-core-utils'
|
||||||
import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@peertube/peertube-models'
|
import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
|
||||||
import { areScalewayObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
import { areScalewayObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
import {
|
import {
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
|
@ -300,7 +300,7 @@ describe('Object storage for video static file privacy', function () {
|
||||||
server,
|
server,
|
||||||
videoUUID: privateVideoUUID,
|
videoUUID: privateVideoUUID,
|
||||||
videoFileToken,
|
videoFileToken,
|
||||||
resolutions: [ 240, 720 ],
|
resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ],
|
||||||
isLive: false
|
isLive: false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -491,7 +491,7 @@ describe('Object storage for video static file privacy', function () {
|
||||||
server,
|
server,
|
||||||
videoUUID: permanentLiveId,
|
videoUUID: permanentLiveId,
|
||||||
videoFileToken,
|
videoFileToken,
|
||||||
resolutions: [ 720 ],
|
resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ],
|
||||||
isLive: true
|
isLive: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -513,8 +513,7 @@ describe('Object storage for video static file privacy', function () {
|
||||||
await server.live.waitUntilWaiting({ videoId: permanentLiveId })
|
await server.live.waitUntilWaiting({ videoId: permanentLiveId })
|
||||||
await waitJobs([ server ])
|
await waitJobs([ server ])
|
||||||
|
|
||||||
const live = await server.videos.getWithToken({ id: permanentLiveId })
|
const replayFromList = await findExternalSavedVideo(server, permanentLiveId)
|
||||||
const replayFromList = await findExternalSavedVideo(server, live)
|
|
||||||
const replay = await server.videos.getWithToken({ id: replayFromList.id })
|
const replay = await server.videos.getWithToken({ id: replayFromList.id })
|
||||||
|
|
||||||
await checkReplay(replay)
|
await checkReplay(replay)
|
||||||
|
|
|
@ -561,7 +561,7 @@ describe('Test runner common actions', function () {
|
||||||
const { data } = await server.runnerJobs.list({ count: 50, sort: '-updatedAt' })
|
const { data } = await server.runnerJobs.list({ count: 50, sort: '-updatedAt' })
|
||||||
|
|
||||||
const children = data.filter(j => j.parent?.uuid === failedJob.uuid)
|
const children = data.filter(j => j.parent?.uuid === failedJob.uuid)
|
||||||
expect(children).to.have.lengthOf(9)
|
expect(children).to.have.lengthOf(5)
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
expect(child.parent.uuid).to.equal(failedJob.uuid)
|
expect(child.parent.uuid).to.equal(failedJob.uuid)
|
||||||
|
@ -599,7 +599,7 @@ describe('Test runner common actions', function () {
|
||||||
{
|
{
|
||||||
const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' })
|
const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' })
|
||||||
const children = data.filter(j => j.parent?.uuid === jobUUID)
|
const children = data.filter(j => j.parent?.uuid === jobUUID)
|
||||||
expect(children).to.have.lengthOf(9)
|
expect(children).to.have.lengthOf(5)
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
expect(child.state.id).to.equal(RunnerJobState.PARENT_CANCELLED)
|
expect(child.state.id).to.equal(RunnerJobState.PARENT_CANCELLED)
|
||||||
|
|
|
@ -119,14 +119,18 @@ describe('Test runner live transcoding', function () {
|
||||||
expect(job.type).to.equal('live-rtmp-hls-transcoding')
|
expect(job.type).to.equal('live-rtmp-hls-transcoding')
|
||||||
expect(job.payload.input.rtmpUrl).to.exist
|
expect(job.payload.input.rtmpUrl).to.exist
|
||||||
|
|
||||||
expect(job.payload.output.toTranscode).to.have.lengthOf(5)
|
expect(job.payload.output.toTranscode).to.have.lengthOf(6)
|
||||||
|
|
||||||
for (const { resolution, fps } of job.payload.output.toTranscode) {
|
for (const { resolution, fps } of job.payload.output.toTranscode) {
|
||||||
expect([ 720, 480, 360, 240, 144 ]).to.contain(resolution)
|
expect([ 720, 480, 360, 240, 144, 0 ]).to.contain(resolution)
|
||||||
|
|
||||||
|
if (resolution === 0) {
|
||||||
|
expect(fps).to.equal(0)
|
||||||
|
} else {
|
||||||
expect(fps).to.be.above(25)
|
expect(fps).to.be.above(25)
|
||||||
expect(fps).to.be.below(70)
|
expect(fps).to.be.below(70)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should update the live with a new chunk', async function () {
|
it('Should update the live with a new chunk', async function () {
|
||||||
|
|
|
@ -23,7 +23,7 @@ describe('Test runner socket', function () {
|
||||||
await setAccessTokensToServers([ server ])
|
await setAccessTokensToServers([ server ])
|
||||||
await setDefaultVideoChannel([ server ])
|
await setDefaultVideoChannel([ server ])
|
||||||
|
|
||||||
await server.config.enableTranscoding({ hls: true, webVideo: true })
|
await server.config.enableTranscoding({ hls: false, webVideo: true })
|
||||||
await server.config.enableRemoteTranscoding()
|
await server.config.enableRemoteTranscoding()
|
||||||
runnerToken = await server.runners.autoRegisterRunner()
|
runnerToken = await server.runners.autoRegisterRunner()
|
||||||
})
|
})
|
||||||
|
|
|
@ -111,6 +111,8 @@ describe('Test runner VOD transcoding', function () {
|
||||||
|
|
||||||
it('Should cancel a transcoding job', async function () {
|
it('Should cancel a transcoding job', async function () {
|
||||||
await servers[0].runnerJobs.cancelAllJobs()
|
await servers[0].runnerJobs.cancelAllJobs()
|
||||||
|
|
||||||
|
await servers[0].config.enableTranscoding({ hls: true, webVideo: false })
|
||||||
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
@ -397,16 +399,18 @@ describe('Test runner VOD transcoding', function () {
|
||||||
await servers[0].runnerJobs.autoProcessWebVideoJob(runnerToken)
|
await servers[0].runnerJobs.autoProcessWebVideoJob(runnerToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have 9 jobs to process', async function () {
|
it('Should have 5 jobs to process', async function () {
|
||||||
const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken })
|
const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken })
|
||||||
|
|
||||||
expect(availableJobs).to.have.lengthOf(9)
|
expect(availableJobs).to.have.lengthOf(5)
|
||||||
|
|
||||||
const webVideoJobs = availableJobs.filter(j => j.type === 'vod-web-video-transcoding')
|
const webVideoJobs = availableJobs.filter(j => j.type === 'vod-web-video-transcoding')
|
||||||
|
|
||||||
|
// Other HLS resolution jobs needs to web video transcoding to be processed first
|
||||||
const hlsJobs = availableJobs.filter(j => j.type === 'vod-hls-transcoding')
|
const hlsJobs = availableJobs.filter(j => j.type === 'vod-hls-transcoding')
|
||||||
|
|
||||||
expect(webVideoJobs).to.have.lengthOf(4)
|
expect(webVideoJobs).to.have.lengthOf(4)
|
||||||
expect(hlsJobs).to.have.lengthOf(5)
|
expect(hlsJobs).to.have.lengthOf(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should process all available jobs', async function () {
|
it('Should process all available jobs', async function () {
|
||||||
|
@ -489,13 +493,13 @@ describe('Test runner VOD transcoding', function () {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have 7 lower resolutions to transcode', async function () {
|
it('Should have 4 lower resolutions to transcode', async function () {
|
||||||
const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken })
|
const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken })
|
||||||
expect(availableJobs).to.have.lengthOf(7)
|
expect(availableJobs).to.have.lengthOf(4)
|
||||||
|
|
||||||
for (const resolution of [ 360, 240, 144 ]) {
|
for (const resolution of [ 360, 240, 144 ]) {
|
||||||
const jobs = availableJobs.filter(j => j.payload.output.resolution === resolution)
|
const jobs = availableJobs.filter(j => j.payload.output.resolution === resolution)
|
||||||
expect(jobs).to.have.lengthOf(2)
|
expect(jobs).to.have.lengthOf(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
jobUUID = availableJobs.find(j => j.payload.output.resolution === 480).uuid
|
jobUUID = availableJobs.find(j => j.payload.output.resolution === 480).uuid
|
||||||
|
|
|
@ -83,6 +83,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
|
||||||
expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true
|
expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true
|
||||||
expect(data.transcoding.webVideos.enabled).to.be.true
|
expect(data.transcoding.webVideos.enabled).to.be.true
|
||||||
expect(data.transcoding.hls.enabled).to.be.true
|
expect(data.transcoding.hls.enabled).to.be.true
|
||||||
|
expect(data.transcoding.hls.splitAudioAndVideo).to.be.false
|
||||||
expect(data.transcoding.originalFile.keep).to.be.false
|
expect(data.transcoding.originalFile.keep).to.be.false
|
||||||
|
|
||||||
expect(data.live.enabled).to.be.false
|
expect(data.live.enabled).to.be.false
|
||||||
|
@ -95,6 +96,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
|
||||||
expect(data.live.transcoding.remoteRunners.enabled).to.be.false
|
expect(data.live.transcoding.remoteRunners.enabled).to.be.false
|
||||||
expect(data.live.transcoding.threads).to.equal(2)
|
expect(data.live.transcoding.threads).to.equal(2)
|
||||||
expect(data.live.transcoding.profile).to.equal('default')
|
expect(data.live.transcoding.profile).to.equal('default')
|
||||||
|
expect(data.live.transcoding.resolutions['0p']).to.be.false
|
||||||
expect(data.live.transcoding.resolutions['144p']).to.be.false
|
expect(data.live.transcoding.resolutions['144p']).to.be.false
|
||||||
expect(data.live.transcoding.resolutions['240p']).to.be.false
|
expect(data.live.transcoding.resolutions['240p']).to.be.false
|
||||||
expect(data.live.transcoding.resolutions['360p']).to.be.false
|
expect(data.live.transcoding.resolutions['360p']).to.be.false
|
||||||
|
@ -257,7 +259,8 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
|
||||||
enabled: true
|
enabled: true
|
||||||
},
|
},
|
||||||
hls: {
|
hls: {
|
||||||
enabled: false
|
enabled: false,
|
||||||
|
splitAudioAndVideo: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
live: {
|
live: {
|
||||||
|
@ -277,6 +280,7 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
|
||||||
threads: 4,
|
threads: 4,
|
||||||
profile: 'live_profile',
|
profile: 'live_profile',
|
||||||
resolutions: {
|
resolutions: {
|
||||||
|
'0p': true,
|
||||||
'144p': true,
|
'144p': true,
|
||||||
'240p': true,
|
'240p': true,
|
||||||
'360p': true,
|
'360p': true,
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { expect } from 'chai'
|
|
||||||
import { dateIsValid } from '@tests/shared/checks.js'
|
|
||||||
import { wait } from '@peertube/peertube-core-utils'
|
import { wait } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import {
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
|
@ -11,6 +9,8 @@ import {
|
||||||
setAccessTokensToServers,
|
setAccessTokensToServers,
|
||||||
waitJobs
|
waitJobs
|
||||||
} from '@peertube/peertube-server-commands'
|
} from '@peertube/peertube-server-commands'
|
||||||
|
import { dateIsValid } from '@tests/shared/checks.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
describe('Test jobs', function () {
|
describe('Test jobs', function () {
|
||||||
let servers: PeerTubeServer[]
|
let servers: PeerTubeServer[]
|
||||||
|
@ -101,12 +101,13 @@ describe('Test jobs', function () {
|
||||||
|
|
||||||
{
|
{
|
||||||
const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' })
|
const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' })
|
||||||
// waiting includes waiting-children
|
// root transcoding
|
||||||
expect(body.data).to.have.lengthOf(4)
|
expect(body.data).to.have.lengthOf(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const body = await servers[1].jobs.list({ state: 'waiting-children', jobType: 'video-transcoding' })
|
const body = await servers[1].jobs.list({ state: 'waiting-children', jobType: 'transcoding-job-builder' })
|
||||||
|
// next transcoding jobs
|
||||||
expect(body.data).to.have.lengthOf(1)
|
expect(body.data).to.have.lengthOf(1)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -51,6 +51,13 @@ describe('Test audio only video transcoding', function () {
|
||||||
await doubleFollow(servers[0], servers[1])
|
await doubleFollow(servers[0], servers[1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const concurrency of [ 1, 2 ]) {
|
||||||
|
describe(`With transcoding concurrency ${concurrency}`, function () {
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
await servers[0].config.setTranscodingConcurrency(concurrency)
|
||||||
|
})
|
||||||
|
|
||||||
it('Should upload a video and transcode it', async function () {
|
it('Should upload a video and transcode it', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
|
@ -97,6 +104,8 @@ describe('Test audio only video transcoding', function () {
|
||||||
expect(size.resolution).to.equal(0)
|
expect(size.resolution).to.equal(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
await cleanupTests(servers)
|
await cleanupTests(servers)
|
||||||
|
|
|
@ -39,7 +39,12 @@ async function checkFilesInObjectStorage (objectStorage: ObjectStorageCommand, v
|
||||||
await makeRawRequest({ url: hlsPlaylist.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url: hlsPlaylist.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
function runTests (enableObjectStorage: boolean) {
|
function runTests (options: {
|
||||||
|
concurrency: number
|
||||||
|
enableObjectStorage: boolean
|
||||||
|
}) {
|
||||||
|
const { concurrency, enableObjectStorage } = options
|
||||||
|
|
||||||
let servers: PeerTubeServer[] = []
|
let servers: PeerTubeServer[] = []
|
||||||
let videoUUID: string
|
let videoUUID: string
|
||||||
let publishedAt: string
|
let publishedAt: string
|
||||||
|
@ -73,6 +78,7 @@ function runTests (enableObjectStorage: boolean) {
|
||||||
publishedAt = video.publishedAt as string
|
publishedAt = video.publishedAt as string
|
||||||
|
|
||||||
await servers[0].config.enableTranscoding()
|
await servers[0].config.enableTranscoding()
|
||||||
|
await servers[0].config.setTranscodingConcurrency(concurrency)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should generate HLS', async function () {
|
it('Should generate HLS', async function () {
|
||||||
|
@ -164,7 +170,7 @@ function runTests (enableObjectStorage: boolean) {
|
||||||
newConfig: {
|
newConfig: {
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
resolutions: ConfigCommand.getCustomConfigResolutions(false),
|
resolutions: ConfigCommand.getConfigResolutions(false),
|
||||||
|
|
||||||
webVideos: {
|
webVideos: {
|
||||||
enabled: true
|
enabled: true
|
||||||
|
@ -200,7 +206,7 @@ function runTests (enableObjectStorage: boolean) {
|
||||||
newConfig: {
|
newConfig: {
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
resolutions: ConfigCommand.getCustomConfigResolutions(true),
|
resolutions: ConfigCommand.getConfigResolutions(true),
|
||||||
|
|
||||||
webVideos: {
|
webVideos: {
|
||||||
enabled: true
|
enabled: true
|
||||||
|
@ -255,13 +261,18 @@ function runTests (enableObjectStorage: boolean) {
|
||||||
|
|
||||||
describe('Test create transcoding jobs from API', function () {
|
describe('Test create transcoding jobs from API', function () {
|
||||||
|
|
||||||
|
for (const concurrency of [ 1, 2 ]) {
|
||||||
|
describe('With concurrency ' + concurrency, function () {
|
||||||
|
|
||||||
describe('On filesystem', function () {
|
describe('On filesystem', function () {
|
||||||
runTests(false)
|
runTests({ concurrency, enableObjectStorage: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('On object storage', function () {
|
describe('On object storage', function () {
|
||||||
if (areMockObjectStorageTestsDisabled()) return
|
if (areMockObjectStorageTestsDisabled()) return
|
||||||
|
|
||||||
runTests(true)
|
runTests({ concurrency, enableObjectStorage: true })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
@ -19,9 +19,25 @@ import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js'
|
||||||
describe('Test HLS videos', function () {
|
describe('Test HLS videos', function () {
|
||||||
let servers: PeerTubeServer[] = []
|
let servers: PeerTubeServer[] = []
|
||||||
|
|
||||||
function runTestSuite (hlsOnly: boolean, objectStorageBaseUrl?: string) {
|
function runTestSuite (options: {
|
||||||
|
hlsOnly: boolean
|
||||||
|
concurrency: number
|
||||||
|
objectStorageBaseUrl?: string
|
||||||
|
}) {
|
||||||
|
const { hlsOnly, objectStorageBaseUrl, concurrency } = options
|
||||||
|
|
||||||
const videoUUIDs: string[] = []
|
const videoUUIDs: string[] = []
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
await servers[0].config.enableTranscoding({
|
||||||
|
resolutions: [ 720, 480, 360, 240 ],
|
||||||
|
hls: true,
|
||||||
|
webVideo: !hlsOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
await servers[0].config.setTranscodingConcurrency(concurrency)
|
||||||
|
})
|
||||||
|
|
||||||
it('Should upload a video and transcode it to HLS', async function () {
|
it('Should upload a video and transcode it to HLS', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
|
@ -112,41 +128,18 @@ describe('Test HLS videos', function () {
|
||||||
await doubleFollow(servers[0], servers[1])
|
await doubleFollow(servers[0], servers[1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const concurrency of [ 1, 2 ]) {
|
||||||
|
describe(`With concurrency ${concurrency}`, function () {
|
||||||
|
|
||||||
describe('With Web Video & HLS enabled', function () {
|
describe('With Web Video & HLS enabled', function () {
|
||||||
runTestSuite(false)
|
runTestSuite({ hlsOnly: false, concurrency })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('With only HLS enabled', function () {
|
describe('With only HLS enabled', function () {
|
||||||
|
runTestSuite({ hlsOnly: true, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
runTestSuite(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('With object storage enabled', function () {
|
describe('With object storage enabled', function () {
|
||||||
if (areMockObjectStorageTestsDisabled()) return
|
if (areMockObjectStorageTestsDisabled()) return
|
||||||
|
@ -163,7 +156,11 @@ describe('Test HLS videos', function () {
|
||||||
await servers[0].run(configOverride)
|
await servers[0].run(configOverride)
|
||||||
})
|
})
|
||||||
|
|
||||||
runTestSuite(true, objectStorage.getMockPlaylistBaseUrl())
|
for (const concurrency of [ 1, 2 ]) {
|
||||||
|
describe(`With concurrency ${concurrency}`, function () {
|
||||||
|
runTestSuite({ hlsOnly: true, concurrency, objectStorageBaseUrl: objectStorage.getMockPlaylistBaseUrl() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
await objectStorage.cleanupMock()
|
await objectStorage.cleanupMock()
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
export * from './audio-only.js'
|
export * from './audio-only.js'
|
||||||
export * from './create-transcoding.js'
|
export * from './create-transcoding.js'
|
||||||
export * from './hls.js'
|
export * from './hls.js'
|
||||||
|
export * from './split-audio-and-video.js'
|
||||||
export * from './transcoder.js'
|
export * from './transcoder.js'
|
||||||
export * from './update-while-transcoding.js'
|
export * from './update-while-transcoding.js'
|
||||||
export * from './video-studio.js'
|
export * from './video-studio.js'
|
||||||
|
|
|
@ -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