Fix live replay duration glitch
This commit is contained in:
parent
543e187262
commit
2650d6d489
|
@ -110,7 +110,7 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
|
|||
// Transcode meta function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
|
||||
type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
|
||||
|
||||
interface BaseTranscodeOptions {
|
||||
type: TranscodeOptionsType
|
||||
|
@ -134,6 +134,14 @@ interface HLSTranscodeOptions extends BaseTranscodeOptions {
|
|||
}
|
||||
}
|
||||
|
||||
interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
|
||||
type: 'hls-from-ts'
|
||||
|
||||
hlsPlaylist: {
|
||||
videoFilename: string
|
||||
}
|
||||
}
|
||||
|
||||
interface QuickTranscodeOptions extends BaseTranscodeOptions {
|
||||
type: 'quick-transcode'
|
||||
}
|
||||
|
@ -153,6 +161,7 @@ interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
|
|||
|
||||
type TranscodeOptions =
|
||||
HLSTranscodeOptions
|
||||
| HLSFromTSTranscodeOptions
|
||||
| VideoTranscodeOptions
|
||||
| MergeAudioTranscodeOptions
|
||||
| OnlyAudioTranscodeOptions
|
||||
|
@ -163,6 +172,7 @@ const builders: {
|
|||
} = {
|
||||
'quick-transcode': buildQuickTranscodeCommand,
|
||||
'hls': buildHLSVODCommand,
|
||||
'hls-from-ts': buildHLSVODFromTSCommand,
|
||||
'merge-audio': buildAudioMergeCommand,
|
||||
'only-audio': buildOnlyAudioCommand,
|
||||
'video': buildx264VODCommand
|
||||
|
@ -292,31 +302,6 @@ function getLiveMuxingCommand (rtmpUrl: string, outPath: string) {
|
|||
return command
|
||||
}
|
||||
|
||||
async function hlsPlaylistToFragmentedMP4 (replayDirectory: string, segmentFiles: string[], outputPath: string) {
|
||||
const concatFilePath = join(replayDirectory, 'concat.txt')
|
||||
|
||||
function cleaner () {
|
||||
remove(concatFilePath)
|
||||
.catch(err => logger.error('Cannot remove concat file in %s.', replayDirectory, { err }))
|
||||
}
|
||||
|
||||
// First concat the ts files to a mp4 file
|
||||
const content = segmentFiles.map(f => 'file ' + f)
|
||||
.join('\n')
|
||||
|
||||
await writeFile(concatFilePath, content + '\n')
|
||||
|
||||
const command = getFFmpeg(concatFilePath)
|
||||
command.inputOption('-safe 0')
|
||||
command.inputOption('-f concat')
|
||||
|
||||
command.outputOption('-c:v copy')
|
||||
command.audioFilter('aresample=async=1:first_pts=0')
|
||||
command.output(outputPath)
|
||||
|
||||
return runCommand(command, cleaner)
|
||||
}
|
||||
|
||||
function buildStreamSuffix (base: string, streamNum?: number) {
|
||||
if (streamNum !== undefined) {
|
||||
return `${base}:${streamNum}`
|
||||
|
@ -336,8 +321,7 @@ export {
|
|||
generateImageFromVideoFile,
|
||||
TranscodeOptions,
|
||||
TranscodeOptionsType,
|
||||
transcode,
|
||||
hlsPlaylistToFragmentedMP4
|
||||
transcode
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -447,6 +431,16 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
|
|||
return command
|
||||
}
|
||||
|
||||
function addCommonHLSVODCommandOptions (command: ffmpeg.FfmpegCommand, outputPath: string) {
|
||||
return command.outputOption('-hls_time 4')
|
||||
.outputOption('-hls_list_size 0')
|
||||
.outputOption('-hls_playlist_type vod')
|
||||
.outputOption('-hls_segment_filename ' + outputPath)
|
||||
.outputOption('-hls_segment_type fmp4')
|
||||
.outputOption('-f hls')
|
||||
.outputOption('-hls_flags single_file')
|
||||
}
|
||||
|
||||
async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
|
||||
const videoPath = getHLSVideoPath(options)
|
||||
|
||||
|
@ -454,19 +448,27 @@ async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTr
|
|||
else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
|
||||
else command = await buildx264VODCommand(command, options)
|
||||
|
||||
command = command.outputOption('-hls_time 4')
|
||||
.outputOption('-hls_list_size 0')
|
||||
.outputOption('-hls_playlist_type vod')
|
||||
.outputOption('-hls_segment_filename ' + videoPath)
|
||||
.outputOption('-hls_segment_type fmp4')
|
||||
.outputOption('-f hls')
|
||||
.outputOption('-hls_flags single_file')
|
||||
addCommonHLSVODCommandOptions(command, videoPath)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
async function buildHLSVODFromTSCommand (command: ffmpeg.FfmpegCommand, options: HLSFromTSTranscodeOptions) {
|
||||
const videoPath = getHLSVideoPath(options)
|
||||
|
||||
command.inputOption('-safe 0')
|
||||
command.inputOption('-f concat')
|
||||
|
||||
command.outputOption('-c:v copy')
|
||||
command.audioFilter('aresample=async=1:first_pts=0')
|
||||
|
||||
addCommonHLSVODCommandOptions(command, videoPath)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
|
||||
if (options.type !== 'hls') return
|
||||
if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
|
||||
|
||||
const fileContent = await readFile(options.outputPath)
|
||||
|
||||
|
@ -480,7 +482,7 @@ async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
|
|||
await writeFile(options.outputPath, newContent)
|
||||
}
|
||||
|
||||
function getHLSVideoPath (options: HLSTranscodeOptions) {
|
||||
function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
|
||||
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import * as Bull from 'bull'
|
||||
import { copy, readdir, remove } from 'fs-extra'
|
||||
import { join } from 'path'
|
||||
import { hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils'
|
||||
import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
|
||||
import { VIDEO_LIVE } from '@server/initializers/constants'
|
||||
import { generateVideoMiniature } from '@server/lib/thumbnail'
|
||||
import { publishAndFederateIfNeeded } from '@server/lib/video'
|
||||
import { getHLSDirectory } from '@server/lib/video-paths'
|
||||
import { generateHlsPlaylist } from '@server/lib/video-transcoding'
|
||||
import { generateHlsPlaylistFromTS } from '@server/lib/video-transcoding'
|
||||
import { VideoModel } from '@server/models/video/video'
|
||||
import { VideoFileModel } from '@server/models/video/video-file'
|
||||
import { VideoLiveModel } from '@server/models/video/video-live'
|
||||
|
@ -71,32 +70,6 @@ async function saveLive (video: MVideo, live: MVideoLive) {
|
|||
}
|
||||
}
|
||||
|
||||
const replayFiles = await readdir(replayDirectory)
|
||||
|
||||
const resolutions: number[] = []
|
||||
let duration: number
|
||||
|
||||
for (const playlistFile of playlistFiles) {
|
||||
const playlistPath = join(replayDirectory, playlistFile)
|
||||
const { videoFileResolution } = await getVideoFileResolution(playlistPath)
|
||||
|
||||
// Put the final mp4 in the hls directory, and not in the replay directory
|
||||
const mp4TmpPath = buildMP4TmpPath(hlsDirectory, videoFileResolution)
|
||||
|
||||
// Playlist name is for example 3.m3u8
|
||||
// Segments names are 3-0.ts 3-1.ts etc
|
||||
const shouldStartWith = playlistFile.replace(/\.m3u8$/, '') + '-'
|
||||
|
||||
const segmentFiles = replayFiles.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts'))
|
||||
await hlsPlaylistToFragmentedMP4(replayDirectory, segmentFiles, mp4TmpPath)
|
||||
|
||||
if (!duration) {
|
||||
duration = await getDurationFromVideoFile(mp4TmpPath)
|
||||
}
|
||||
|
||||
resolutions.push(videoFileResolution)
|
||||
}
|
||||
|
||||
await cleanupLiveFiles(hlsDirectory)
|
||||
|
||||
await live.destroy()
|
||||
|
@ -105,7 +78,6 @@ async function saveLive (video: MVideo, live: MVideoLive) {
|
|||
// Reinit views
|
||||
video.views = 0
|
||||
video.state = VideoState.TO_TRANSCODE
|
||||
video.duration = duration
|
||||
|
||||
await video.save()
|
||||
|
||||
|
@ -116,20 +88,34 @@ async function saveLive (video: MVideo, live: MVideoLive) {
|
|||
await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id)
|
||||
hlsPlaylist.VideoFiles = []
|
||||
|
||||
for (const resolution of resolutions) {
|
||||
const videoInputPath = buildMP4TmpPath(hlsDirectory, resolution)
|
||||
const { isPortraitMode } = await getVideoFileResolution(videoInputPath)
|
||||
const replayFiles = await readdir(replayDirectory)
|
||||
let duration: number
|
||||
|
||||
await generateHlsPlaylist({
|
||||
for (const playlistFile of playlistFiles) {
|
||||
const playlistPath = join(replayDirectory, playlistFile)
|
||||
const { videoFileResolution, isPortraitMode } = await getVideoFileResolution(playlistPath)
|
||||
|
||||
// Playlist name is for example 3.m3u8
|
||||
// Segments names are 3-0.ts 3-1.ts etc
|
||||
const shouldStartWith = playlistFile.replace(/\.m3u8$/, '') + '-'
|
||||
|
||||
const segmentFiles = replayFiles.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts'))
|
||||
|
||||
const outputPath = await generateHlsPlaylistFromTS({
|
||||
video: videoWithFiles,
|
||||
videoInputPath,
|
||||
resolution: resolution,
|
||||
copyCodecs: true,
|
||||
replayDirectory,
|
||||
segmentFiles,
|
||||
resolution: videoFileResolution,
|
||||
isPortraitMode
|
||||
})
|
||||
|
||||
await remove(videoInputPath)
|
||||
if (!duration) {
|
||||
videoWithFiles.duration = await getDurationFromVideoFile(outputPath)
|
||||
await videoWithFiles.save()
|
||||
}
|
||||
}
|
||||
|
||||
await remove(replayDirectory)
|
||||
|
||||
// Regenerate the thumbnail & preview?
|
||||
if (videoWithFiles.getMiniature().automaticallyGenerated === true) {
|
||||
|
@ -161,8 +147,7 @@ async function cleanupLiveFiles (hlsDirectory: string) {
|
|||
filename.endsWith('.m3u8') ||
|
||||
filename.endsWith('.mpd') ||
|
||||
filename.endsWith('.m4s') ||
|
||||
filename.endsWith('.tmp') ||
|
||||
filename === VIDEO_LIVE.REPLAY_DIRECTORY
|
||||
filename.endsWith('.tmp')
|
||||
) {
|
||||
const p = join(hlsDirectory, filename)
|
||||
|
||||
|
@ -171,7 +156,3 @@ async function cleanupLiveFiles (hlsDirectory: string) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMP4TmpPath (basePath: string, resolution: number) {
|
||||
return join(basePath, resolution + '-tmp.mp4')
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
|
||||
import { copyFile, ensureDir, move, remove, stat, writeFile } from 'fs-extra'
|
||||
import { basename, extname as extnameUtil, join } from 'path'
|
||||
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
|
||||
import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models'
|
||||
|
@ -163,15 +163,104 @@ async function mergeAudioVideofile (video: MVideoWithAllFiles, resolution: Video
|
|||
return onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
|
||||
}
|
||||
|
||||
// Concat TS segments from a live video to a fragmented mp4 HLS playlist
|
||||
async function generateHlsPlaylistFromTS (options: {
|
||||
video: MVideoWithFile
|
||||
replayDirectory: string
|
||||
segmentFiles: string[]
|
||||
resolution: VideoResolution
|
||||
isPortraitMode: boolean
|
||||
}) {
|
||||
const concatFilePath = join(options.replayDirectory, 'concat.txt')
|
||||
|
||||
function cleaner () {
|
||||
remove(concatFilePath)
|
||||
.catch(err => logger.error('Cannot remove concat file in %s.', options.replayDirectory, { err }))
|
||||
}
|
||||
|
||||
// First concat the ts files to a mp4 file
|
||||
const content = options.segmentFiles.map(f => 'file ' + f)
|
||||
.join('\n')
|
||||
|
||||
await writeFile(concatFilePath, content + '\n')
|
||||
|
||||
try {
|
||||
const outputPath = await generateHlsPlaylistCommon({
|
||||
video: options.video,
|
||||
resolution: options.resolution,
|
||||
isPortraitMode: options.isPortraitMode,
|
||||
inputPath: concatFilePath,
|
||||
type: 'hls-from-ts' as 'hls-from-ts'
|
||||
})
|
||||
|
||||
cleaner()
|
||||
|
||||
return outputPath
|
||||
} catch (err) {
|
||||
cleaner()
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Generate an HLS playlist from an input file, and update the master playlist
|
||||
async function generateHlsPlaylist (options: {
|
||||
function generateHlsPlaylist (options: {
|
||||
video: MVideoWithFile
|
||||
videoInputPath: string
|
||||
resolution: VideoResolution
|
||||
copyCodecs: boolean
|
||||
isPortraitMode: boolean
|
||||
}) {
|
||||
const { video, videoInputPath, resolution, copyCodecs, isPortraitMode } = options
|
||||
return generateHlsPlaylistCommon({
|
||||
video: options.video,
|
||||
resolution: options.resolution,
|
||||
copyCodecs: options.copyCodecs,
|
||||
isPortraitMode: options.isPortraitMode,
|
||||
inputPath: options.videoInputPath,
|
||||
type: 'hls' as 'hls'
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
generateHlsPlaylist,
|
||||
generateHlsPlaylistFromTS,
|
||||
optimizeOriginalVideofile,
|
||||
transcodeNewResolution,
|
||||
mergeAudioVideofile
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function onVideoFileTranscoding (video: MVideoWithFile, videoFile: MVideoFile, transcodingPath: string, outputPath: string) {
|
||||
const stats = await stat(transcodingPath)
|
||||
const fps = await getVideoFileFPS(transcodingPath)
|
||||
const metadata = await getMetadataFromFile(transcodingPath)
|
||||
|
||||
await move(transcodingPath, outputPath, { overwrite: true })
|
||||
|
||||
videoFile.size = stats.size
|
||||
videoFile.fps = fps
|
||||
videoFile.metadata = metadata
|
||||
|
||||
await createTorrentAndSetInfoHash(video, videoFile)
|
||||
|
||||
await VideoFileModel.customUpsert(videoFile, 'video', undefined)
|
||||
video.VideoFiles = await video.$get('VideoFiles')
|
||||
|
||||
return video
|
||||
}
|
||||
|
||||
async function generateHlsPlaylistCommon (options: {
|
||||
type: 'hls' | 'hls-from-ts'
|
||||
video: MVideoWithFile
|
||||
inputPath: string
|
||||
resolution: VideoResolution
|
||||
copyCodecs?: boolean
|
||||
isPortraitMode: boolean
|
||||
}) {
|
||||
const { type, video, inputPath, resolution, copyCodecs, isPortraitMode } = options
|
||||
|
||||
const baseHlsDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
|
||||
await ensureDir(join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid))
|
||||
|
@ -180,9 +269,9 @@ async function generateHlsPlaylist (options: {
|
|||
const videoFilename = generateVideoStreamingPlaylistName(video.uuid, resolution)
|
||||
|
||||
const transcodeOptions = {
|
||||
type: 'hls' as 'hls',
|
||||
type,
|
||||
|
||||
inputPath: videoInputPath,
|
||||
inputPath,
|
||||
outputPath,
|
||||
|
||||
availableEncoders,
|
||||
|
@ -242,35 +331,5 @@ async function generateHlsPlaylist (options: {
|
|||
await updateMasterHLSPlaylist(video)
|
||||
await updateSha256VODSegments(video)
|
||||
|
||||
return video
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
generateHlsPlaylist,
|
||||
optimizeOriginalVideofile,
|
||||
transcodeNewResolution,
|
||||
mergeAudioVideofile
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function onVideoFileTranscoding (video: MVideoWithFile, videoFile: MVideoFile, transcodingPath: string, outputPath: string) {
|
||||
const stats = await stat(transcodingPath)
|
||||
const fps = await getVideoFileFPS(transcodingPath)
|
||||
const metadata = await getMetadataFromFile(transcodingPath)
|
||||
|
||||
await move(transcodingPath, outputPath, { overwrite: true })
|
||||
|
||||
videoFile.size = stats.size
|
||||
videoFile.fps = fps
|
||||
videoFile.metadata = metadata
|
||||
|
||||
await createTorrentAndSetInfoHash(video, videoFile)
|
||||
|
||||
await VideoFileModel.customUpsert(videoFile, 'video', undefined)
|
||||
video.VideoFiles = await video.$get('VideoFiles')
|
||||
|
||||
return video
|
||||
return outputPath
|
||||
}
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import * as Bluebird from 'bluebird'
|
||||
import { join } from 'path'
|
||||
import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
|
||||
import {
|
||||
AllowNull,
|
||||
BelongsTo,
|
||||
|
@ -15,14 +18,19 @@ import {
|
|||
Table,
|
||||
UpdatedAt
|
||||
} from 'sequelize-typescript'
|
||||
import { MAccountId, MChannelId } from '@server/types/models'
|
||||
import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
|
||||
import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
|
||||
import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
|
||||
import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils'
|
||||
import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
|
||||
import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
|
||||
import { activityPubCollectionPagination } from '../../helpers/activitypub'
|
||||
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
|
||||
import {
|
||||
isVideoPlaylistDescriptionValid,
|
||||
isVideoPlaylistNameValid,
|
||||
isVideoPlaylistPrivacyValid
|
||||
} from '../../helpers/custom-validators/video-playlists'
|
||||
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
|
||||
import {
|
||||
ACTIVITY_PUB,
|
||||
CONSTRAINTS_FIELDS,
|
||||
|
@ -32,18 +40,7 @@ import {
|
|||
VIDEO_PLAYLIST_TYPES,
|
||||
WEBSERVER
|
||||
} from '../../initializers/constants'
|
||||
import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
|
||||
import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
|
||||
import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
|
||||
import { join } from 'path'
|
||||
import { VideoPlaylistElementModel } from './video-playlist-element'
|
||||
import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
|
||||
import { activityPubCollectionPagination } from '../../helpers/activitypub'
|
||||
import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
|
||||
import { ThumbnailModel } from './thumbnail'
|
||||
import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
|
||||
import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
|
||||
import * as Bluebird from 'bluebird'
|
||||
import { MThumbnail } from '../../types/models/video/thumbnail'
|
||||
import {
|
||||
MVideoPlaylistAccountThumbnail,
|
||||
MVideoPlaylistAP,
|
||||
|
@ -52,8 +49,11 @@ import {
|
|||
MVideoPlaylistFullSummary,
|
||||
MVideoPlaylistIdWithElements
|
||||
} from '../../types/models/video/video-playlist'
|
||||
import { MThumbnail } from '../../types/models/video/thumbnail'
|
||||
import { MAccountId, MChannelId } from '@server/types/models'
|
||||
import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
|
||||
import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils'
|
||||
import { ThumbnailModel } from './thumbnail'
|
||||
import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
|
||||
import { VideoPlaylistElementModel } from './video-playlist-element'
|
||||
|
||||
enum ScopeNames {
|
||||
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
|
||||
|
|
|
@ -430,6 +430,8 @@ describe('Test live', function () {
|
|||
expect(video.files).to.have.lengthOf(0)
|
||||
|
||||
const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS)
|
||||
await makeRawRequest(hlsPlaylist.playlistUrl, 200)
|
||||
await makeRawRequest(hlsPlaylist.segmentsSha256Url, 200)
|
||||
|
||||
expect(hlsPlaylist.files).to.have.lengthOf(resolutions.length)
|
||||
|
||||
|
|
Loading…
Reference in New Issue