mirror of https://github.com/Chocobozzz/PeerTube
Merge branch 'release/5.0.0' into develop
commit
b8598d40f6
|
@ -15,6 +15,7 @@ describe('Private videos all workflow', () => {
|
||||||
let playerPage: PlayerPage
|
let playerPage: PlayerPage
|
||||||
|
|
||||||
const internalVideoName = 'Internal E2E test'
|
const internalVideoName = 'Internal E2E test'
|
||||||
|
const internalHLSOnlyVideoName = 'Internal E2E test - HLS only'
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||||
|
@ -44,6 +45,13 @@ describe('Private videos all workflow', () => {
|
||||||
await checkCorrectlyPlay(playerPage)
|
await checkCorrectlyPlay(playerPage)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should play an internal HLS only video', async () => {
|
||||||
|
await go(FIXTURE_URLS.INTERNAL_HLS_ONLY_VIDEO)
|
||||||
|
|
||||||
|
await videoWatchPage.waitWatchVideoName(internalHLSOnlyVideoName)
|
||||||
|
await checkCorrectlyPlay(playerPage)
|
||||||
|
})
|
||||||
|
|
||||||
it('Should play an internal WebTorrent video in embed', async () => {
|
it('Should play an internal WebTorrent video in embed', async () => {
|
||||||
await go(FIXTURE_URLS.INTERNAL_EMBED_WEBTORRENT_VIDEO)
|
await go(FIXTURE_URLS.INTERNAL_EMBED_WEBTORRENT_VIDEO)
|
||||||
|
|
||||||
|
@ -57,4 +65,11 @@ describe('Private videos all workflow', () => {
|
||||||
await videoWatchPage.waitEmbedForDisplayed()
|
await videoWatchPage.waitEmbedForDisplayed()
|
||||||
await checkCorrectlyPlay(playerPage)
|
await checkCorrectlyPlay(playerPage)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should play an internal HLS only video in embed', async () => {
|
||||||
|
await go(FIXTURE_URLS.INTERNAL_EMBED_HLS_ONLY_VIDEO)
|
||||||
|
|
||||||
|
await videoWatchPage.waitEmbedForDisplayed()
|
||||||
|
await checkCorrectlyPlay(playerPage)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
const FIXTURE_URLS = {
|
const FIXTURE_URLS = {
|
||||||
INTERNAL_WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=webtorrent&start=0',
|
INTERNAL_WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=webtorrent&start=0',
|
||||||
INTERNAL_HLS_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?start=0',
|
INTERNAL_HLS_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?start=0',
|
||||||
|
|
||||||
INTERNAL_EMBED_WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?mode=webtorrent&start=0',
|
INTERNAL_EMBED_WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?mode=webtorrent&start=0',
|
||||||
INTERNAL_EMBED_HLS_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?start=0',
|
INTERNAL_EMBED_HLS_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?start=0',
|
||||||
|
|
||||||
|
INTERNAL_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/w/tKQmHcqdYZRdCszLUiWM3V?start=0',
|
||||||
|
INTERNAL_EMBED_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/videos/embed/tKQmHcqdYZRdCszLUiWM3V?start=0',
|
||||||
|
|
||||||
WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/w/122d093a-1ede-43bd-bd34-59d2931ffc5e',
|
WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/w/122d093a-1ede-43bd-bd34-59d2931ffc5e',
|
||||||
|
|
||||||
HLS_EMBED: 'https://peertube2.cpy.re/videos/embed/969bf103-7818-43b5-94a0-de159e13de50',
|
HLS_EMBED: 'https://peertube2.cpy.re/videos/embed/969bf103-7818-43b5-94a0-de159e13de50',
|
||||||
|
|
|
@ -175,7 +175,7 @@ export class VideoStatsComponent implements OnInit {
|
||||||
this.statsService.getOverallStats({ videoId: this.video.uuid, startDate: this.statsStartDate, endDate: this.statsEndDate })
|
this.statsService.getOverallStats({ videoId: this.video.uuid, startDate: this.statsStartDate, endDate: this.statsEndDate })
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: res => {
|
next: res => {
|
||||||
this.countries = res.countries.slice(0, 10).map(c => ({
|
this.countries = res.countries.map(c => ({
|
||||||
name: this.countryCodeToName(c.isoCode),
|
name: this.countryCodeToName(c.isoCode),
|
||||||
viewers: c.viewers
|
viewers: c.viewers
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -32,6 +32,7 @@ export class HLSOptionsBuilder {
|
||||||
|
|
||||||
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
|
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
|
||||||
requiresAuth: commonOptions.requiresAuth,
|
requiresAuth: commonOptions.requiresAuth,
|
||||||
|
videoFileToken: commonOptions.videoFileToken,
|
||||||
|
|
||||||
redundancyUrlManager,
|
redundancyUrlManager,
|
||||||
type: 'application/x-mpegURL',
|
type: 'application/x-mpegURL',
|
||||||
|
|
|
@ -3,7 +3,7 @@ import videojs from 'video.js'
|
||||||
import { Events, Segment } from '@peertube/p2p-media-loader-core'
|
import { Events, Segment } from '@peertube/p2p-media-loader-core'
|
||||||
import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs'
|
import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { timeToInt } from '@shared/core-utils'
|
import { addQueryParams, timeToInt } from '@shared/core-utils'
|
||||||
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
|
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
|
||||||
import { registerConfigPlugin, registerSourceHandler } from './hls-plugin'
|
import { registerConfigPlugin, registerSourceHandler } from './hls-plugin'
|
||||||
|
|
||||||
|
@ -39,46 +39,37 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
super(player)
|
super(player)
|
||||||
|
|
||||||
this.options = options
|
this.options = options
|
||||||
|
this.startTime = timeToInt(options.startTime)
|
||||||
|
|
||||||
// FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
|
// FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
|
||||||
if (!(videojs as any).Html5Hlsjs) {
|
if (!(videojs as any).Html5Hlsjs) {
|
||||||
logger.warn('HLS.js does not seem to be supported. Try to fallback to built in HLS.')
|
if (player.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
this.fallbackToBuiltInIOS()
|
||||||
let message: string
|
|
||||||
if (!player.canPlayType('application/vnd.apple.mpegurl')) {
|
|
||||||
message = 'Cannot fallback to built-in HLS'
|
|
||||||
} else if (options.requiresAuth) {
|
|
||||||
message = 'Video requires auth which is not compatible to build-in HLS player'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message) {
|
|
||||||
logger.warn(message)
|
|
||||||
|
|
||||||
const error: MediaError = {
|
|
||||||
code: MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
|
||||||
message,
|
|
||||||
MEDIA_ERR_ABORTED: MediaError.MEDIA_ERR_ABORTED,
|
|
||||||
MEDIA_ERR_DECODE: MediaError.MEDIA_ERR_DECODE,
|
|
||||||
MEDIA_ERR_NETWORK: MediaError.MEDIA_ERR_NETWORK,
|
|
||||||
MEDIA_ERR_SRC_NOT_SUPPORTED: MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
|
||||||
}
|
|
||||||
|
|
||||||
player.ready(() => player.error(error))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workaround to force video.js to not re create a video element
|
const message = 'HLS.js does not seem to be supported. Cannot fallback to built-in HLS'
|
||||||
(this.player as any).playerElIngest_ = this.player.el().parentNode
|
logger.warn(message)
|
||||||
} else {
|
|
||||||
// FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
|
|
||||||
(videojs as any).Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => {
|
|
||||||
this.hlsjs = hlsjs
|
|
||||||
})
|
|
||||||
|
|
||||||
initVideoJsContribHlsJsPlayer(player)
|
const error: MediaError = {
|
||||||
|
code: MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
||||||
|
message,
|
||||||
|
MEDIA_ERR_ABORTED: MediaError.MEDIA_ERR_ABORTED,
|
||||||
|
MEDIA_ERR_DECODE: MediaError.MEDIA_ERR_DECODE,
|
||||||
|
MEDIA_ERR_NETWORK: MediaError.MEDIA_ERR_NETWORK,
|
||||||
|
MEDIA_ERR_SRC_NOT_SUPPORTED: MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||||
|
}
|
||||||
|
|
||||||
|
player.ready(() => player.error(error))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.startTime = timeToInt(options.startTime)
|
// FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
|
||||||
|
(videojs as any).Html5Hlsjs.addHook('beforeinitialize', (_videojsPlayer: any, hlsjs: any) => {
|
||||||
|
this.hlsjs = hlsjs
|
||||||
|
})
|
||||||
|
|
||||||
|
initVideoJsContribHlsJsPlayer(player)
|
||||||
|
|
||||||
player.src({
|
player.src({
|
||||||
type: options.type,
|
type: options.type,
|
||||||
|
@ -88,9 +79,7 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
player.ready(() => {
|
player.ready(() => {
|
||||||
this.initializeCore()
|
this.initializeCore()
|
||||||
|
|
||||||
if ((videojs as any).Html5Hlsjs) {
|
this.initializePlugin()
|
||||||
this.initializePlugin()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -199,6 +188,25 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
private arraySum (data: number[]) {
|
private arraySum (data: number[]) {
|
||||||
return data.reduce((a: number, b: number) => a + b, 0)
|
return data.reduce((a: number, b: number) => a + b, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fallbackToBuiltInIOS () {
|
||||||
|
logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.');
|
||||||
|
|
||||||
|
// Workaround to force video.js to not re create a video element
|
||||||
|
(this.player as any).playerElIngest_ = this.player.el().parentNode
|
||||||
|
|
||||||
|
this.player.src({
|
||||||
|
type: this.options.type,
|
||||||
|
src: addQueryParams(this.options.src, {
|
||||||
|
videoFileToken: this.options.videoFileToken(),
|
||||||
|
reinjectVideoFileToken: 'true'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.player.ready(() => {
|
||||||
|
this.initializeCore()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin)
|
videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin)
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { basename } from 'path'
|
||||||
import { Segment } from '@peertube/p2p-media-loader-core'
|
import { Segment } from '@peertube/p2p-media-loader-core'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { wait } from '@root-helpers/utils'
|
import { wait } from '@root-helpers/utils'
|
||||||
|
import { removeQueryParams } from '@shared/core-utils'
|
||||||
import { isSameOrigin } from '../common'
|
import { isSameOrigin } from '../common'
|
||||||
|
|
||||||
type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
|
type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
|
||||||
|
@ -24,7 +25,7 @@ function segmentValidatorFactory (options: {
|
||||||
// Wait for hash generation from the server
|
// Wait for hash generation from the server
|
||||||
if (isLive) await wait(1000)
|
if (isLive) await wait(1000)
|
||||||
|
|
||||||
const filename = basename(segment.url)
|
const filename = basename(removeQueryParams(segment.url))
|
||||||
|
|
||||||
const segmentValue = (await segmentsJSON)[filename]
|
const segmentValue = (await segmentsJSON)[filename]
|
||||||
|
|
||||||
|
|
|
@ -168,6 +168,7 @@ type P2PMediaLoaderPluginOptions = {
|
||||||
loader: P2PMediaLoader
|
loader: P2PMediaLoader
|
||||||
|
|
||||||
requiresAuth: boolean
|
requiresAuth: boolean
|
||||||
|
videoFileToken: () => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type P2PMediaLoader = {
|
export type P2PMediaLoader = {
|
||||||
|
|
|
@ -14,9 +14,3 @@ $height: 40px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vjs-modal-dialog
|
|
||||||
.vjs-modal-dialog-content,
|
|
||||||
.video-js .vjs-modal-dialog {
|
|
||||||
top: $height;
|
|
||||||
}
|
|
||||||
|
|
|
@ -202,6 +202,10 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vjs-modal-dialog-content {
|
||||||
|
padding-top: 40px !important;
|
||||||
|
}
|
||||||
|
|
||||||
// Error display disabled
|
// Error display disabled
|
||||||
.vjs-error:not(.vjs-error-display-enabled) {
|
.vjs-error:not(.vjs-error-display-enabled) {
|
||||||
.vjs-custom-error-display {
|
.vjs-custom-error-display {
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import { PassThrough, pipeline } from 'stream'
|
||||||
import { logger } from '@server/helpers/logger'
|
import { logger } from '@server/helpers/logger'
|
||||||
|
import { StreamReplacer } from '@server/helpers/stream-replacer'
|
||||||
import { OBJECT_STORAGE_PROXY_PATHS } from '@server/initializers/constants'
|
import { OBJECT_STORAGE_PROXY_PATHS } from '@server/initializers/constants'
|
||||||
|
import { injectQueryToPlaylistUrls } from '@server/lib/hls'
|
||||||
import { getHLSFileReadStream, getWebTorrentFileReadStream } from '@server/lib/object-storage'
|
import { getHLSFileReadStream, getWebTorrentFileReadStream } from '@server/lib/object-storage'
|
||||||
import {
|
import {
|
||||||
asyncMiddleware,
|
asyncMiddleware,
|
||||||
|
@ -11,6 +14,7 @@ import {
|
||||||
optionalAuthenticate
|
optionalAuthenticate
|
||||||
} from '@server/middlewares'
|
} from '@server/middlewares'
|
||||||
import { HttpStatusCode } from '@shared/models'
|
import { HttpStatusCode } from '@shared/models'
|
||||||
|
import { buildReinjectVideoFileTokenQuery, doReinjectVideoFileToken } from './shared/m3u8-playlist'
|
||||||
|
|
||||||
const objectStorageProxyRouter = express.Router()
|
const objectStorageProxyRouter = express.Router()
|
||||||
|
|
||||||
|
@ -67,7 +71,20 @@ async function proxifyHLS (req: express.Request, res: express.Response) {
|
||||||
rangeHeader: req.header('range')
|
rangeHeader: req.header('range')
|
||||||
})
|
})
|
||||||
|
|
||||||
return stream.pipe(res)
|
const streamReplacer = filename.endsWith('.m3u8') && doReinjectVideoFileToken(req)
|
||||||
|
? new StreamReplacer(line => injectQueryToPlaylistUrls(line, buildReinjectVideoFileTokenQuery(req)))
|
||||||
|
: new PassThrough()
|
||||||
|
|
||||||
|
return pipeline(
|
||||||
|
stream,
|
||||||
|
streamReplacer,
|
||||||
|
res,
|
||||||
|
err => {
|
||||||
|
if (!err) return
|
||||||
|
|
||||||
|
handleObjectStorageFailure(res, err)
|
||||||
|
}
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleObjectStorageFailure(res, err)
|
return handleObjectStorageFailure(res, err)
|
||||||
}
|
}
|
||||||
|
@ -75,6 +92,7 @@ async function proxifyHLS (req: express.Request, res: express.Response) {
|
||||||
|
|
||||||
function handleObjectStorageFailure (res: express.Response, err: Error) {
|
function handleObjectStorageFailure (res: express.Response, err: Error) {
|
||||||
if (err.name === 'NoSuchKey') {
|
if (err.name === 'NoSuchKey') {
|
||||||
|
logger.debug('Could not find key in object storage to proxify private HLS video file.', { err })
|
||||||
return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
|
return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
import express from 'express'
|
||||||
|
|
||||||
|
function doReinjectVideoFileToken (req: express.Request) {
|
||||||
|
return req.query.videoFileToken && req.query.reinjectVideoFileToken
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReinjectVideoFileTokenQuery (req: express.Request) {
|
||||||
|
return 'videoFileToken=' + req.query.videoFileToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
doReinjectVideoFileToken,
|
||||||
|
buildReinjectVideoFileTokenQuery
|
||||||
|
}
|
|
@ -1,5 +1,8 @@
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import { readFile } from 'fs-extra'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { injectQueryToPlaylistUrls } from '@server/lib/hls'
|
||||||
import {
|
import {
|
||||||
asyncMiddleware,
|
asyncMiddleware,
|
||||||
ensureCanAccessPrivateVideoHLSFiles,
|
ensureCanAccessPrivateVideoHLSFiles,
|
||||||
|
@ -7,8 +10,10 @@ import {
|
||||||
handleStaticError,
|
handleStaticError,
|
||||||
optionalAuthenticate
|
optionalAuthenticate
|
||||||
} from '@server/middlewares'
|
} from '@server/middlewares'
|
||||||
|
import { HttpStatusCode } from '@shared/models'
|
||||||
import { CONFIG } from '../initializers/config'
|
import { CONFIG } from '../initializers/config'
|
||||||
import { DIRECTORIES, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers/constants'
|
import { DIRECTORIES, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers/constants'
|
||||||
|
import { buildReinjectVideoFileTokenQuery, doReinjectVideoFileToken } from './shared/m3u8-playlist'
|
||||||
|
|
||||||
const staticRouter = express.Router()
|
const staticRouter = express.Router()
|
||||||
|
|
||||||
|
@ -49,6 +54,12 @@ const privateHLSStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AU
|
||||||
? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessPrivateVideoHLSFiles) ]
|
? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessPrivateVideoHLSFiles) ]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
staticRouter.use(
|
||||||
|
STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:playlistName.m3u8',
|
||||||
|
...privateHLSStaticMiddlewares,
|
||||||
|
asyncMiddleware(servePrivateM3U8)
|
||||||
|
)
|
||||||
|
|
||||||
staticRouter.use(
|
staticRouter.use(
|
||||||
STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS,
|
STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS,
|
||||||
...privateHLSStaticMiddlewares,
|
...privateHLSStaticMiddlewares,
|
||||||
|
@ -74,3 +85,31 @@ staticRouter.use(
|
||||||
export {
|
export {
|
||||||
staticRouter
|
staticRouter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function servePrivateM3U8 (req: express.Request, res: express.Response) {
|
||||||
|
const path = join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, req.params.videoUUID, req.params.playlistName + '.m3u8')
|
||||||
|
|
||||||
|
let playlistContent: string
|
||||||
|
|
||||||
|
try {
|
||||||
|
playlistContent = await readFile(path, 'utf-8')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('ENOENT')) {
|
||||||
|
return res.fail({
|
||||||
|
status: HttpStatusCode.NOT_FOUND_404,
|
||||||
|
message: 'File not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject token in playlist so players that cannot alter the HTTP request can still watch the video
|
||||||
|
const transformedContent = doReinjectVideoFileToken(req)
|
||||||
|
? injectQueryToPlaylistUrls(playlistContent, buildReinjectVideoFileTokenQuery(req))
|
||||||
|
: playlistContent
|
||||||
|
|
||||||
|
return res.set('content-type', 'application/vnd.apple.mpegurl').send(transformedContent).end()
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Transform, TransformCallback } from 'stream'
|
||||||
|
|
||||||
|
// Thanks: https://stackoverflow.com/a/45126242
|
||||||
|
class StreamReplacer extends Transform {
|
||||||
|
private pendingChunk: Buffer
|
||||||
|
|
||||||
|
constructor (private readonly replacer: (line: string) => string) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
_transform (chunk: Buffer, _encoding: BufferEncoding, done: TransformCallback) {
|
||||||
|
try {
|
||||||
|
this.pendingChunk = this.pendingChunk?.length
|
||||||
|
? Buffer.concat([ this.pendingChunk, chunk ])
|
||||||
|
: chunk
|
||||||
|
|
||||||
|
let index: number
|
||||||
|
|
||||||
|
// As long as we keep finding newlines, keep making slices of the buffer and push them to the
|
||||||
|
// readable side of the transform stream
|
||||||
|
while ((index = this.pendingChunk.indexOf('\n')) !== -1) {
|
||||||
|
// The `end` parameter is non-inclusive, so increase it to include the newline we found
|
||||||
|
const line = this.pendingChunk.slice(0, ++index)
|
||||||
|
|
||||||
|
// `start` is inclusive, but we are already one char ahead of the newline -> all good
|
||||||
|
this.pendingChunk = this.pendingChunk.slice(index)
|
||||||
|
|
||||||
|
// We have a single line here! Prepend the string we want
|
||||||
|
this.push(this.doReplace(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
return done()
|
||||||
|
} catch (err) {
|
||||||
|
return done(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_flush (done: TransformCallback) {
|
||||||
|
// If we have any remaining data in the cache, send it out
|
||||||
|
if (!this.pendingChunk?.length) return done()
|
||||||
|
|
||||||
|
try {
|
||||||
|
return done(null, this.doReplace(this.pendingChunk))
|
||||||
|
} catch (err) {
|
||||||
|
return done(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private doReplace (buffer: Buffer) {
|
||||||
|
const line = this.replacer(buffer.toString('utf8'))
|
||||||
|
|
||||||
|
return Buffer.from(line, 'utf8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
StreamReplacer
|
||||||
|
}
|
|
@ -234,13 +234,20 @@ function downloadPlaylistSegments (playlistUrl: string, destinationDir: string,
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function injectQueryToPlaylistUrls (content: string, queryString: string) {
|
||||||
|
return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
updateMasterHLSPlaylist,
|
updateMasterHLSPlaylist,
|
||||||
updateSha256VODSegments,
|
updateSha256VODSegments,
|
||||||
buildSha256Segment,
|
buildSha256Segment,
|
||||||
downloadPlaylistSegments,
|
downloadPlaylistSegments,
|
||||||
updateStreamingPlaylistsInfohashesIfNeeded,
|
updateStreamingPlaylistsInfohashesIfNeeded,
|
||||||
updatePlaylistAfterFileChange
|
updatePlaylistAfterFileChange,
|
||||||
|
injectQueryToPlaylistUrls
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -2,7 +2,7 @@ import express from 'express'
|
||||||
import { query } from 'express-validator'
|
import { query } from 'express-validator'
|
||||||
import LRUCache from 'lru-cache'
|
import LRUCache from 'lru-cache'
|
||||||
import { basename, dirname } from 'path'
|
import { basename, dirname } from 'path'
|
||||||
import { exists, isUUIDValid } from '@server/helpers/custom-validators/misc'
|
import { exists, isUUIDValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc'
|
||||||
import { logger } from '@server/helpers/logger'
|
import { logger } from '@server/helpers/logger'
|
||||||
import { LRU_CACHE } from '@server/initializers/constants'
|
import { LRU_CACHE } from '@server/initializers/constants'
|
||||||
import { VideoModel } from '@server/models/video/video'
|
import { VideoModel } from '@server/models/video/video'
|
||||||
|
@ -60,7 +60,14 @@ const ensureCanAccessVideoPrivateWebTorrentFiles = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const ensureCanAccessPrivateVideoHLSFiles = [
|
const ensureCanAccessPrivateVideoHLSFiles = [
|
||||||
query('videoFileToken').optional().custom(exists),
|
query('videoFileToken')
|
||||||
|
.optional()
|
||||||
|
.custom(exists),
|
||||||
|
|
||||||
|
query('reinjectVideoFileToken')
|
||||||
|
.optional()
|
||||||
|
.customSanitizer(toBooleanOrNull)
|
||||||
|
.isBoolean().withMessage('Should be a valid reinjectVideoFileToken boolean'),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
|
@ -44,7 +44,7 @@ const usersListValidator = [
|
||||||
query('blocked')
|
query('blocked')
|
||||||
.optional()
|
.optional()
|
||||||
.customSanitizer(toBooleanOrNull)
|
.customSanitizer(toBooleanOrNull)
|
||||||
.isBoolean().withMessage('Should be a valid blocked boolena'),
|
.isBoolean().withMessage('Should be a valid blocked boolean'),
|
||||||
|
|
||||||
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import { basename } from 'path'
|
import { basename } from 'path'
|
||||||
import { expectStartWith } from '@server/tests/shared'
|
import { checkVideoFileTokenReinjection, expectStartWith } from '@server/tests/shared'
|
||||||
import { areScalewayObjectStorageTestsDisabled, getAllFiles, getHLS } from '@shared/core-utils'
|
import { areScalewayObjectStorageTestsDisabled, getAllFiles, getHLS } from '@shared/core-utils'
|
||||||
import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@shared/models'
|
import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@shared/models'
|
||||||
import {
|
import {
|
||||||
|
@ -191,6 +191,20 @@ describe('Object storage for video static file privacy', function () {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should reinject video file token', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: privateVideoUUID })
|
||||||
|
|
||||||
|
await checkVideoFileTokenReinjection({
|
||||||
|
server,
|
||||||
|
videoUUID: privateVideoUUID,
|
||||||
|
videoFileToken,
|
||||||
|
resolutions: [ 240, 720 ],
|
||||||
|
isLive: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('Should update public video to private', async function () {
|
it('Should update public video to private', async function () {
|
||||||
this.timeout(60000)
|
this.timeout(60000)
|
||||||
|
|
||||||
|
@ -315,6 +329,26 @@ describe('Object storage for video static file privacy', function () {
|
||||||
await checkLiveFiles(permanentLive, permanentLiveId)
|
await checkLiveFiles(permanentLive, permanentLiveId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should reinject video file token in permanent live', async function () {
|
||||||
|
this.timeout(240000)
|
||||||
|
|
||||||
|
const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: permanentLive.rtmpUrl, streamKey: permanentLive.streamKey })
|
||||||
|
await server.live.waitUntilPublished({ videoId: permanentLiveId })
|
||||||
|
|
||||||
|
const video = await server.videos.getWithToken({ id: permanentLiveId })
|
||||||
|
const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
|
||||||
|
|
||||||
|
await checkVideoFileTokenReinjection({
|
||||||
|
server,
|
||||||
|
videoUUID: permanentLiveId,
|
||||||
|
videoFileToken,
|
||||||
|
resolutions: [ 720 ],
|
||||||
|
isLive: true
|
||||||
|
})
|
||||||
|
|
||||||
|
await stopFfmpeg(ffmpegCommand)
|
||||||
|
})
|
||||||
|
|
||||||
it('Should have created a replay of the normal live with a private static path', async function () {
|
it('Should have created a replay of the normal live with a private static path', async function () {
|
||||||
this.timeout(240000)
|
this.timeout(240000)
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import { decode } from 'magnet-uri'
|
import { decode } from 'magnet-uri'
|
||||||
import { expectStartWith } from '@server/tests/shared'
|
import { checkVideoFileTokenReinjection, expectStartWith } from '@server/tests/shared'
|
||||||
import { getAllFiles, wait } from '@shared/core-utils'
|
import { getAllFiles, wait } from '@shared/core-utils'
|
||||||
import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@shared/models'
|
import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@shared/models'
|
||||||
import {
|
import {
|
||||||
|
@ -248,6 +248,35 @@ describe('Test video static file privacy', function () {
|
||||||
await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken })
|
await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should reinject video file token', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
|
||||||
|
|
||||||
|
const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid })
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
const video = await server.videos.getWithToken({ id: uuid })
|
||||||
|
const hls = video.streamingPlaylists[0]
|
||||||
|
|
||||||
|
{
|
||||||
|
const query = { videoFileToken }
|
||||||
|
const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
expect(text).to.not.include(videoFileToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await checkVideoFileTokenReinjection({
|
||||||
|
server,
|
||||||
|
videoUUID: uuid,
|
||||||
|
videoFileToken,
|
||||||
|
resolutions: [ 240, 720 ],
|
||||||
|
isLive: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('Should be able to access a private video of another user with an admin OAuth token or file token', async function () {
|
it('Should be able to access a private video of another user with an admin OAuth token or file token', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
|
@ -360,6 +389,36 @@ describe('Test video static file privacy', function () {
|
||||||
await checkLiveFiles(permanentLive, permanentLiveId)
|
await checkLiveFiles(permanentLive, permanentLiveId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should reinject video file token on permanent live', async function () {
|
||||||
|
this.timeout(240000)
|
||||||
|
|
||||||
|
const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: permanentLive.rtmpUrl, streamKey: permanentLive.streamKey })
|
||||||
|
await server.live.waitUntilPublished({ videoId: permanentLiveId })
|
||||||
|
|
||||||
|
const video = await server.videos.getWithToken({ id: permanentLiveId })
|
||||||
|
const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
|
||||||
|
const hls = video.streamingPlaylists[0]
|
||||||
|
|
||||||
|
{
|
||||||
|
const query = { videoFileToken }
|
||||||
|
const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
expect(text).to.not.include(videoFileToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await checkVideoFileTokenReinjection({
|
||||||
|
server,
|
||||||
|
videoUUID: permanentLiveId,
|
||||||
|
videoFileToken,
|
||||||
|
resolutions: [ 720 ],
|
||||||
|
isLive: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await stopFfmpeg(ffmpegCommand)
|
||||||
|
})
|
||||||
|
|
||||||
it('Should have created a replay of the normal live with a private static path', async function () {
|
it('Should have created a replay of the normal live with a private static path', async function () {
|
||||||
this.timeout(240000)
|
this.timeout(240000)
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,12 @@ function expectNotStartWith (str: string, start: string) {
|
||||||
expect(str.startsWith(start), `${str} does not start with ${start}`).to.be.false
|
expect(str.startsWith(start), `${str} does not start with ${start}`).to.be.false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectEndWith (str: string, end: string) {
|
||||||
|
expect(str.endsWith(end), `${str} does not end with ${end}`).to.be.true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function expectLogDoesNotContain (server: PeerTubeServer, str: string) {
|
async function expectLogDoesNotContain (server: PeerTubeServer, str: string) {
|
||||||
const content = await server.servers.getLogContent()
|
const content = await server.servers.getLogContent()
|
||||||
|
|
||||||
|
@ -103,6 +109,7 @@ export {
|
||||||
testFileExistsOrNot,
|
testFileExistsOrNot,
|
||||||
expectStartWith,
|
expectStartWith,
|
||||||
expectNotStartWith,
|
expectNotStartWith,
|
||||||
|
expectEndWith,
|
||||||
checkBadStartPagination,
|
checkBadStartPagination,
|
||||||
checkBadCountPagination,
|
checkBadCountPagination,
|
||||||
checkBadSortPagination,
|
checkBadSortPagination,
|
||||||
|
|
|
@ -6,7 +6,7 @@ export * from './directories'
|
||||||
export * from './generate'
|
export * from './generate'
|
||||||
export * from './live'
|
export * from './live'
|
||||||
export * from './notifications'
|
export * from './notifications'
|
||||||
export * from './playlists'
|
export * from './video-playlists'
|
||||||
export * from './plugins'
|
export * from './plugins'
|
||||||
export * from './requests'
|
export * from './requests'
|
||||||
export * from './streaming-playlists'
|
export * from './streaming-playlists'
|
||||||
|
|
|
@ -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 { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import { basename } from 'path'
|
import { basename, dirname, join } from 'path'
|
||||||
import { removeFragmentedMP4Ext, uuidRegex } from '@shared/core-utils'
|
import { removeFragmentedMP4Ext, uuidRegex } from '@shared/core-utils'
|
||||||
import { sha256 } from '@shared/extra-utils'
|
import { sha256 } from '@shared/extra-utils'
|
||||||
import { HttpStatusCode, VideoStreamingPlaylist, VideoStreamingPlaylistType } from '@shared/models'
|
import { HttpStatusCode, VideoStreamingPlaylist, VideoStreamingPlaylistType } from '@shared/models'
|
||||||
|
@ -188,9 +188,55 @@ async function completeCheckHlsPlaylist (options: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkVideoFileTokenReinjection (options: {
|
||||||
|
server: PeerTubeServer
|
||||||
|
videoUUID: string
|
||||||
|
videoFileToken: string
|
||||||
|
resolutions: number[]
|
||||||
|
isLive: boolean
|
||||||
|
}) {
|
||||||
|
const { server, resolutions, videoFileToken, videoUUID, isLive } = options
|
||||||
|
|
||||||
|
const video = await server.videos.getWithToken({ id: videoUUID })
|
||||||
|
const hls = video.streamingPlaylists[0]
|
||||||
|
|
||||||
|
const query = { videoFileToken, reinjectVideoFileToken: 'true' }
|
||||||
|
const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
for (let i = 0; i < resolutions.length; i++) {
|
||||||
|
const resolution = resolutions[i]
|
||||||
|
|
||||||
|
const suffix = isLive
|
||||||
|
? i
|
||||||
|
: `-${resolution}`
|
||||||
|
|
||||||
|
expect(text).to.contain(`${suffix}.m3u8?videoFileToken=${videoFileToken}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolutionPlaylists = extractResolutionPlaylistUrls(hls.playlistUrl, text)
|
||||||
|
expect(resolutionPlaylists).to.have.lengthOf(resolutions.length)
|
||||||
|
|
||||||
|
for (const url of resolutionPlaylists) {
|
||||||
|
const { text } = await makeRawRequest({ url, query, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
const extension = isLive
|
||||||
|
? '.ts'
|
||||||
|
: '.mp4'
|
||||||
|
|
||||||
|
expect(text).to.contain(`${extension}?videoFileToken=${videoFileToken}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractResolutionPlaylistUrls (masterPath: string, masterContent: string) {
|
||||||
|
return masterContent.match(/^([^.]+\.m3u8.*)/mg)
|
||||||
|
.map(filename => join(dirname(masterPath), filename))
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
checkSegmentHash,
|
checkSegmentHash,
|
||||||
checkLiveSegmentHash,
|
checkLiveSegmentHash,
|
||||||
checkResolutionsInMasterPlaylist,
|
checkResolutionsInMasterPlaylist,
|
||||||
completeCheckHlsPlaylist
|
completeCheckHlsPlaylist,
|
||||||
|
extractResolutionPlaylistUrls,
|
||||||
|
checkVideoFileTokenReinjection
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,14 @@ function addQueryParams (url: string, params: { [ id: string ]: string }) {
|
||||||
return objUrl.toString()
|
return objUrl.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeQueryParams (url: string) {
|
||||||
|
const objUrl = new URL(url)
|
||||||
|
|
||||||
|
objUrl.searchParams.forEach((_v, k) => objUrl.searchParams.delete(k))
|
||||||
|
|
||||||
|
return objUrl.toString()
|
||||||
|
}
|
||||||
|
|
||||||
function buildPlaylistLink (playlist: Pick<VideoPlaylist, 'shortUUID'>, base?: string) {
|
function buildPlaylistLink (playlist: Pick<VideoPlaylist, 'shortUUID'>, base?: string) {
|
||||||
return (base ?? window.location.origin) + buildPlaylistWatchPath(playlist)
|
return (base ?? window.location.origin) + buildPlaylistWatchPath(playlist)
|
||||||
}
|
}
|
||||||
|
@ -114,6 +122,7 @@ function decoratePlaylistLink (options: {
|
||||||
|
|
||||||
export {
|
export {
|
||||||
addQueryParams,
|
addQueryParams,
|
||||||
|
removeQueryParams,
|
||||||
|
|
||||||
buildPlaylistLink,
|
buildPlaylistLink,
|
||||||
buildVideoLink,
|
buildVideoLink,
|
||||||
|
|
|
@ -7,16 +7,24 @@ export class StreamingPlaylistsCommand extends AbstractCommand {
|
||||||
|
|
||||||
async get (options: OverrideCommandOptions & {
|
async get (options: OverrideCommandOptions & {
|
||||||
url: string
|
url: string
|
||||||
|
|
||||||
|
videoFileToken?: string
|
||||||
|
reinjectVideoFileToken?: boolean
|
||||||
|
|
||||||
withRetry?: boolean // default false
|
withRetry?: boolean // default false
|
||||||
currentRetry?: number
|
currentRetry?: number
|
||||||
}) {
|
}) {
|
||||||
const { withRetry, currentRetry = 1 } = options
|
const { videoFileToken, reinjectVideoFileToken, withRetry, currentRetry = 1 } = options
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await unwrapTextOrDecode(this.getRawRequest({
|
const result = await unwrapTextOrDecode(this.getRawRequest({
|
||||||
...options,
|
...options,
|
||||||
|
|
||||||
url: options.url,
|
url: options.url,
|
||||||
|
query: {
|
||||||
|
videoFileToken,
|
||||||
|
reinjectVideoFileToken
|
||||||
|
},
|
||||||
implicitToken: false,
|
implicitToken: false,
|
||||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -408,6 +408,7 @@ paths:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/staticFilename'
|
- $ref: '#/components/parameters/staticFilename'
|
||||||
- $ref: '#/components/parameters/videoFileToken'
|
- $ref: '#/components/parameters/videoFileToken'
|
||||||
|
- $ref: '#/components/parameters/reinjectVideoFileToken'
|
||||||
security:
|
security:
|
||||||
- OAuth2: []
|
- OAuth2: []
|
||||||
responses:
|
responses:
|
||||||
|
@ -5711,7 +5712,13 @@ components:
|
||||||
description: Video file token [generated](#operation/requestVideoToken) by PeerTube so you don't need to provide an OAuth token in the request header.
|
description: Video file token [generated](#operation/requestVideoToken) by PeerTube so you don't need to provide an OAuth token in the request header.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
reinjectVideoFileToken:
|
||||||
|
name: reinjectVideoFileToken
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Ask the server to reinject videoFileToken in URLs in m3u8 playlist
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
OAuth2:
|
OAuth2:
|
||||||
|
|
Loading…
Reference in New Issue