"ngx-qrcode2": "^0.0.9",
"node-sass": "^4.9.3",
"npm-font-source-sans-pro": "^1.0.2",
- "p2p-media-loader-hlsjs": "^0.3.0",
+ "p2p-media-loader-hlsjs": "^0.4.0",
"path-browserify": "^1.0.0",
"primeng": "^7.0.0",
"process": "^0.11.10",
}
computeQuotaWithTranscoding () {
- const resolutions = this.serverService.getConfig().transcoding.enabledResolutions
+ const transcodingConfig = this.serverService.getConfig().transcoding
+
+ const resolutions = transcodingConfig.enabledResolutions
const higherResolution = VideoResolution.H_1080P
let multiplier = 0
multiplier += resolution / higherResolution
}
+ if (transcodingConfig.hls.enabled) multiplier *= 2
+
return multiplier * parseInt(this.form.value['videoQuota'], 10)
}
requiresEmailVerification: false
},
transcoding: {
- enabledResolutions: []
+ enabledResolutions: [],
+ hls: {
+ enabled: false
+ }
},
avatar: {
file: {
import { Video } from '../../shared/video/video.model'
import { Account } from '@app/shared/account/account.model'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
+import { VideoStreamingPlaylist } from '../../../../../shared/models/videos/video-streaming-playlist.model'
+import { VideoStreamingPlaylistType } from '../../../../../shared/models/videos/video-streaming-playlist.type'
export class VideoDetails extends Video implements VideoDetailsServerModel {
descriptionPath: string
likesPercent: number
dislikesPercent: number
+ trackerUrls: string[]
+
+ streamingPlaylists: VideoStreamingPlaylist[]
+
constructor (hash: VideoDetailsServerModel, translations = {}) {
super(hash, translations)
this.support = hash.support
this.commentsEnabled = hash.commentsEnabled
+ this.trackerUrls = hash.trackerUrls
+ this.streamingPlaylists = hash.streamingPlaylists
+
this.buildLikeAndDislikePercents()
}
this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100
this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100
}
+
+ getHlsPlaylist () {
+ return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
+ }
}
import { environment } from '../../../environments/environment'
import { VideoCaptionService } from '@app/shared/video-caption'
import { MarkdownService } from '@app/shared/renderer'
-import { PeertubePlayerManager } from '../../../assets/player/peertube-player-manager'
+import { P2PMediaLoaderOptions, PeertubePlayerManager, PlayerMode, WebtorrentOptions } from '../../../assets/player/peertube-player-manager'
@Component({
selector: 'my-video-watch',
serverUrl: environment.apiUrl,
videoCaptions: playerCaptions
- },
+ }
+ }
- webtorrent: {
+ let mode: PlayerMode
+ const hlsPlaylist = this.video.getHlsPlaylist()
+ if (hlsPlaylist) {
+ mode = 'p2p-media-loader'
+ const p2pMediaLoader = {
+ playlistUrl: hlsPlaylist.playlistUrl,
+ segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
+ redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
+ trackerAnnounce: this.video.trackerUrls,
videoFiles: this.video.files
- }
+ } as P2PMediaLoaderOptions
+
+ Object.assign(options, { p2pMediaLoader })
+ } else {
+ mode = 'webtorrent'
+ const webtorrent = {
+ videoFiles: this.video.files
+ } as WebtorrentOptions
+
+ Object.assign(options, { webtorrent })
}
this.zone.runOutsideAngular(async () => {
- this.player = await PeertubePlayerManager.initialize('webtorrent', options)
+ this.player = await PeertubePlayerManager.initialize(mode, options)
this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
})
+++ /dev/null
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import * as videojs from 'video.js'
-import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from './peertube-videojs-typings'
-
-// videojs-hlsjs-plugin needs videojs in window
-window['videojs'] = videojs
-require('@streamroot/videojs-hlsjs-plugin')
-
-import { Engine, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
-import { Events } from 'p2p-media-loader-core'
-
-const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
-class P2pMediaLoaderPlugin extends Plugin {
-
- private readonly CONSTANTS = {
- INFO_SCHEDULER: 1000 // Don't change this
- }
-
- private hlsjs: any // Don't type hlsjs to not bundle the module
- private p2pEngine: Engine
- private statsP2PBytes = {
- pendingDownload: [] as number[],
- pendingUpload: [] as number[],
- numPeers: 0,
- totalDownload: 0,
- totalUpload: 0
- }
-
- private networkInfoInterval: any
-
- constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) {
- super(player, options)
-
- videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => {
- this.hlsjs = hlsjs
-
- this.initialize()
- })
-
- initVideoJsContribHlsJsPlayer(player)
-
- player.src({
- type: options.type,
- src: options.src
- })
- }
-
- dispose () {
- clearInterval(this.networkInfoInterval)
- }
-
- private initialize () {
- this.p2pEngine = this.player.tech_.options_.hlsjsConfig.loader.getEngine()
-
- // Avoid using constants to not import hls.hs
- // https://github.com/video-dev/hls.js/blob/master/src/events.js#L37
- this.hlsjs.on('hlsLevelSwitching', (_: any, data: any) => {
- this.trigger('resolutionChange', { auto: this.hlsjs.autoLevelEnabled, resolutionId: data.height })
- })
-
- this.runStats()
- }
-
- private runStats () {
- this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, size: number) => {
- if (method === 'p2p') {
- this.statsP2PBytes.pendingDownload.push(size)
- this.statsP2PBytes.totalDownload += size
- }
- })
-
- this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, size: number) => {
- if (method === 'p2p') {
- this.statsP2PBytes.pendingUpload.push(size)
- this.statsP2PBytes.totalUpload += size
- }
- })
-
- this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++)
- this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--)
-
- this.networkInfoInterval = setInterval(() => {
- let downloadSpeed = this.statsP2PBytes.pendingDownload.reduce((a: number, b: number) => a + b, 0)
- let uploadSpeed = this.statsP2PBytes.pendingUpload.reduce((a: number, b: number) => a + b, 0)
-
- this.statsP2PBytes.pendingDownload = []
- this.statsP2PBytes.pendingUpload = []
-
- return this.player.trigger('p2pInfo', {
- p2p: {
- downloadSpeed,
- uploadSpeed,
- numPeers: this.statsP2PBytes.numPeers,
- downloaded: this.statsP2PBytes.totalDownload,
- uploaded: this.statsP2PBytes.totalUpload
- }
- } as PlayerNetworkInfo)
- }, this.CONSTANTS.INFO_SCHEDULER)
- }
-}
-
-videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin)
-export { P2pMediaLoaderPlugin }
--- /dev/null
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import * as videojs from 'video.js'
+import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from '../peertube-videojs-typings'
+import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
+import { Events } from 'p2p-media-loader-core'
+
+// videojs-hlsjs-plugin needs videojs in window
+window['videojs'] = videojs
+require('@streamroot/videojs-hlsjs-plugin')
+
+const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
+class P2pMediaLoaderPlugin extends Plugin {
+
+ private readonly CONSTANTS = {
+ INFO_SCHEDULER: 1000 // Don't change this
+ }
+ private readonly options: P2PMediaLoaderPluginOptions
+
+ private hlsjs: any // Don't type hlsjs to not bundle the module
+ private p2pEngine: Engine
+ private statsP2PBytes = {
+ pendingDownload: [] as number[],
+ pendingUpload: [] as number[],
+ numPeers: 0,
+ totalDownload: 0,
+ totalUpload: 0
+ }
+ private statsHTTPBytes = {
+ pendingDownload: [] as number[],
+ pendingUpload: [] as number[],
+ totalDownload: 0,
+ totalUpload: 0
+ }
+
+ private networkInfoInterval: any
+
+ constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) {
+ super(player, options)
+
+ this.options = options
+
+ videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => {
+ this.hlsjs = hlsjs
+ })
+
+ initVideoJsContribHlsJsPlayer(player)
+
+ player.src({
+ type: options.type,
+ src: options.src
+ })
+
+ player.ready(() => this.initialize())
+ }
+
+ dispose () {
+ clearInterval(this.networkInfoInterval)
+ }
+
+ private initialize () {
+ initHlsJsPlayer(this.hlsjs)
+
+ this.p2pEngine = this.player.tech_.options_.hlsjsConfig.loader.getEngine()
+
+ // Avoid using constants to not import hls.hs
+ // https://github.com/video-dev/hls.js/blob/master/src/events.js#L37
+ this.hlsjs.on('hlsLevelSwitching', (_: any, data: any) => {
+ this.trigger('resolutionChange', { auto: this.hlsjs.autoLevelEnabled, resolutionId: data.height })
+ })
+
+ this.p2pEngine.on(Events.SegmentError, (segment, err) => {
+ console.error('Segment error.', segment, err)
+ })
+
+ this.statsP2PBytes.numPeers = 1 + this.options.redundancyBaseUrls.length
+
+ this.runStats()
+ }
+
+ private runStats () {
+ this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, size: number) => {
+ const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes
+
+ elem.pendingDownload.push(size)
+ elem.totalDownload += size
+ })
+
+ this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, size: number) => {
+ const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes
+
+ elem.pendingUpload.push(size)
+ elem.totalUpload += size
+ })
+
+ this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++)
+ this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--)
+
+ this.networkInfoInterval = setInterval(() => {
+ const p2pDownloadSpeed = this.arraySum(this.statsP2PBytes.pendingDownload)
+ const p2pUploadSpeed = this.arraySum(this.statsP2PBytes.pendingUpload)
+
+ const httpDownloadSpeed = this.arraySum(this.statsHTTPBytes.pendingDownload)
+ const httpUploadSpeed = this.arraySum(this.statsHTTPBytes.pendingUpload)
+
+ this.statsP2PBytes.pendingDownload = []
+ this.statsP2PBytes.pendingUpload = []
+ this.statsHTTPBytes.pendingDownload = []
+ this.statsHTTPBytes.pendingUpload = []
+
+ return this.player.trigger('p2pInfo', {
+ http: {
+ downloadSpeed: httpDownloadSpeed,
+ uploadSpeed: httpUploadSpeed,
+ downloaded: this.statsHTTPBytes.totalDownload,
+ uploaded: this.statsHTTPBytes.totalUpload
+ },
+ p2p: {
+ downloadSpeed: p2pDownloadSpeed,
+ uploadSpeed: p2pUploadSpeed,
+ numPeers: this.statsP2PBytes.numPeers,
+ downloaded: this.statsP2PBytes.totalDownload,
+ uploaded: this.statsP2PBytes.totalUpload
+ }
+ } as PlayerNetworkInfo)
+ }, this.CONSTANTS.INFO_SCHEDULER)
+ }
+
+ private arraySum (data: number[]) {
+ return data.reduce((a: number, b: number) => a + b, 0)
+ }
+}
+
+videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin)
+export { P2pMediaLoaderPlugin }
--- /dev/null
+import { basename } from 'path'
+import { Segment } from 'p2p-media-loader-core'
+
+function segmentUrlBuilderFactory (baseUrls: string[]) {
+ return function segmentBuilder (segment: Segment) {
+ const max = baseUrls.length + 1
+ const i = getRandomInt(max)
+
+ if (i === max - 1) return segment.url
+
+ let newBaseUrl = baseUrls[i]
+ let middlePart = newBaseUrl.endsWith('/') ? '' : '/'
+
+ return newBaseUrl + middlePart + basename(segment.url)
+ }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ segmentUrlBuilderFactory
+}
+
+// ---------------------------------------------------------------------------
+
+function getRandomInt (max: number) {
+ return Math.floor(Math.random() * Math.floor(max))
+}
--- /dev/null
+import { Segment } from 'p2p-media-loader-core'
+import { basename } from 'path'
+
+function segmentValidatorFactory (segmentsSha256Url: string) {
+ const segmentsJSON = fetchSha256Segments(segmentsSha256Url)
+
+ return async function segmentValidator (segment: Segment) {
+ const segmentName = basename(segment.url)
+
+ const hashShouldBe = (await segmentsJSON)[segmentName]
+ if (hashShouldBe === undefined) {
+ throw new Error(`Unknown segment name ${segmentName} in segment validator`)
+ }
+
+ const calculatedSha = bufferToEx(await sha256(segment.data))
+ if (calculatedSha !== hashShouldBe) {
+ throw new Error(`Hashes does not correspond for segment ${segmentName} (expected: ${hashShouldBe} instead of ${calculatedSha})`)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ segmentValidatorFactory
+}
+
+// ---------------------------------------------------------------------------
+
+function fetchSha256Segments (url: string) {
+ return fetch(url)
+ .then(res => res.json())
+ .catch(err => {
+ console.error('Cannot get sha256 segments', err)
+ return {}
+ })
+}
+
+function sha256 (data?: ArrayBuffer) {
+ if (!data) return undefined
+
+ return window.crypto.subtle.digest('SHA-256', data)
+}
+
+// Thanks: https://stackoverflow.com/a/53307879
+function bufferToEx (buffer?: ArrayBuffer) {
+ if (!buffer) return ''
+
+ let s = ''
+ const h = '0123456789abcdef'
+ const o = new Uint8Array(buffer)
+
+ o.forEach((v: any) => s += h[ v >> 4 ] + h[ v & 15 ])
+
+ return s
+}
import './videojs-components/peertube-load-progress-bar'
import './videojs-components/theater-button'
import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings'
-import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
+import { buildVideoEmbed, buildVideoLink, copyToClipboard, getRtcConfig } from './utils'
import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
+import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
+import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
export type P2PMediaLoaderOptions = {
playlistUrl: string
+ segmentsSha256Url: string
trackerAnnounce: string[]
+ redundancyBaseUrls: string[]
+ videoFiles: VideoFile[]
}
export type CommonOptions = {
static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) {
let p2pMediaLoader: any
- if (mode === 'webtorrent') await import('./webtorrent-plugin')
+ if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin')
if (mode === 'p2p-media-loader') {
[ p2pMediaLoader ] = await Promise.all([
import('p2p-media-loader-hlsjs'),
- import('./p2p-media-loader-plugin')
+ import('./p2p-media-loader/p2p-media-loader-plugin')
])
}
const commonOptions = options.common
const webtorrentOptions = options.webtorrent
const p2pMediaLoaderOptions = options.p2pMediaLoader
+
+ let autoplay = options.common.autoplay
let html5 = {}
const plugins: VideoJSPluginOptions = {
peertube: {
- autoplay: commonOptions.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
+ mode,
+ autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
videoViewUrl: commonOptions.videoViewUrl,
videoDuration: commonOptions.videoDuration,
startTime: commonOptions.startTime,
if (p2pMediaLoaderOptions) {
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
+ redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls,
type: 'application/x-mpegURL',
src: p2pMediaLoaderOptions.playlistUrl
}
+ const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce
+ .filter(t => t.startsWith('ws'))
+
const p2pMediaLoaderConfig = {
- // loader: {
- // trackerAnnounce: p2pMediaLoaderOptions.trackerAnnounce
- // },
+ loader: {
+ trackerAnnounce,
+ segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url),
+ rtcConfig: getRtcConfig(),
+ requiredSegmentsPriority: 5,
+ segmentUrlBuilder: segmentUrlBuilderFactory(options.p2pMediaLoader.redundancyBaseUrls)
+ },
segments: {
swarmId: p2pMediaLoaderOptions.playlistUrl
}
}
const streamrootHls = {
+ levelLabelHandler: (level: { height: number, width: number }) => {
+ const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === level.height)
+
+ let label = file.resolution.label
+ if (file.fps >= 50) label += file.fps
+
+ return label
+ },
html5: {
hlsjsConfig: {
liveSyncDurationCount: 7,
if (webtorrentOptions) {
const webtorrent = {
- autoplay: commonOptions.autoplay,
+ autoplay,
videoDuration: commonOptions.videoDuration,
playerElement: commonOptions.playerElement,
videoFiles: webtorrentOptions.videoFiles
}
Object.assign(plugins, { webtorrent })
+
+ // WebTorrent plugin handles autoplay, because we do some hackish stuff in there
+ autoplay = false
}
const videojsOptions = {
: undefined, // Undefined so the player knows it has to check the local storage
poster: commonOptions.poster,
- autoplay: false,
+ autoplay,
inactivityTimeout: commonOptions.inactivityTimeout,
playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ],
plugins,
this.player.ready(() => {
const playerOptions = this.player.options_
- if (this.player.webtorrent) {
+ if (options.mode === 'webtorrent') {
this.player.webtorrent().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d))
this.player.webtorrent().on('autoResolutionChange', (_: any, d: any) => this.trigger('autoResolutionChange', d))
}
- if (this.player.p2pMediaLoader) {
+ if (options.mode === 'p2p-media-loader') {
this.player.p2pMediaLoader().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d))
}
import { VideoFile } from '../../../../shared/models/videos/video.model'
import { PeerTubePlugin } from './peertube-plugin'
-import { WebTorrentPlugin } from './webtorrent-plugin'
+import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin'
+import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin'
+import { PlayerMode } from './peertube-player-manager'
declare namespace videojs {
interface Player {
peertube (): PeerTubePlugin
webtorrent (): WebTorrentPlugin
+ p2pMediaLoader (): P2pMediaLoaderPlugin
}
}
}
type PeerTubePluginOptions = {
+ mode: PlayerMode
+
autoplay: boolean
videoViewUrl: string
videoDuration: number
}
type P2PMediaLoaderPluginOptions = {
+ redundancyBaseUrls: string[]
type: string
src: string
}
}
type PlayerNetworkInfo = {
+ http: {
+ downloadSpeed: number
+ uploadSpeed: number
+ downloaded: number
+ uploaded: number
+ }
+
p2p: {
downloadSpeed: number
uploadSpeed: number
return min
}
+function getRtcConfig () {
+ return {
+ iceServers: [
+ {
+ urls: 'stun:stun.stunprotocol.org'
+ },
+ {
+ urls: 'stun:stun.framasoft.org'
+ }
+ ]
+ }
+}
+
// ---------------------------------------------------------------------------
export {
+ getRtcConfig,
toTitleCase,
timeToInt,
buildVideoLink,
}
const p2pStats = data.p2p
+ const httpStats = data.http
- const downloadSpeed = bytes(p2pStats.downloadSpeed)
- const uploadSpeed = bytes(p2pStats.uploadSpeed)
- const totalDownloaded = bytes(p2pStats.downloaded)
- const totalUploaded = bytes(p2pStats.uploaded)
+ const downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed)
+ const uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed)
+ const totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded)
+ const totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded)
const numPeers = p2pStats.numPeers
subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' +
uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ]
peersNumber.textContent = numPeers
- peersText.textContent = ' ' + this.player_.localize('peers')
+ peersText.textContent = ' ' + (numPeers > 1 ? this.player_.localize('peers') : this.player_.localize('peer'))
subDivHttp.className = 'vjs-peertube-hidden'
subDivWebtorrent.className = 'vjs-peertube-displayed'
+++ /dev/null
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import * as videojs from 'video.js'
-
-import * as WebTorrent from 'webtorrent'
-import { VideoFile } from '../../../../shared/models/videos/video.model'
-import { renderVideo } from './webtorrent/video-renderer'
-import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from './peertube-videojs-typings'
-import { videoFileMaxByResolution, videoFileMinByResolution } from './utils'
-import { PeertubeChunkStore } from './webtorrent/peertube-chunk-store'
-import {
- getAverageBandwidthInStore,
- getStoredMute,
- getStoredVolume,
- getStoredWebTorrentEnabled,
- saveAverageBandwidth
-} from './peertube-player-local-storage'
-
-const CacheChunkStore = require('cache-chunk-store')
-
-type PlayOptions = {
- forcePlay?: boolean,
- seek?: number,
- delay?: number
-}
-
-const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
-class WebTorrentPlugin extends Plugin {
- private readonly playerElement: HTMLVideoElement
-
- private readonly autoplay: boolean = false
- private readonly startTime: number = 0
- private readonly savePlayerSrcFunction: Function
- private readonly videoFiles: VideoFile[]
- private readonly videoDuration: number
- private readonly CONSTANTS = {
- INFO_SCHEDULER: 1000, // Don't change this
- AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds
- AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
- AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
- AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
- BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth
- }
-
- private readonly webtorrent = new WebTorrent({
- tracker: {
- rtcConfig: {
- iceServers: [
- {
- urls: 'stun:stun.stunprotocol.org'
- },
- {
- urls: 'stun:stun.framasoft.org'
- }
- ]
- }
- },
- dht: false
- })
-
- private player: any
- private currentVideoFile: VideoFile
- private torrent: WebTorrent.Torrent
-
- private renderer: any
- private fakeRenderer: any
- private destroyingFakeRenderer = false
-
- private autoResolution = true
- private autoResolutionPossible = true
- private isAutoResolutionObservation = false
- private playerRefusedP2P = false
-
- private torrentInfoInterval: any
- private autoQualityInterval: any
- private addTorrentDelay: any
- private qualityObservationTimer: any
- private runAutoQualitySchedulerTimer: any
-
- private downloadSpeeds: number[] = []
-
- constructor (player: videojs.Player, options: WebtorrentPluginOptions) {
- super(player, options)
-
- // Disable auto play on iOS
- this.autoplay = options.autoplay && this.isIOS() === false
- this.playerRefusedP2P = !getStoredWebTorrentEnabled()
-
- this.videoFiles = options.videoFiles
- this.videoDuration = options.videoDuration
-
- this.savePlayerSrcFunction = this.player.src
- this.playerElement = options.playerElement
-
- this.player.ready(() => {
- const playerOptions = this.player.options_
-
- const volume = getStoredVolume()
- if (volume !== undefined) this.player.volume(volume)
-
- const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
- if (muted !== undefined) this.player.muted(muted)
-
- this.player.duration(options.videoDuration)
-
- this.initializePlayer()
- this.runTorrentInfoScheduler()
-
- this.player.one('play', () => {
- // Don't run immediately scheduler, wait some seconds the TCP connections are made
- this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
- })
- })
- }
-
- dispose () {
- clearTimeout(this.addTorrentDelay)
- clearTimeout(this.qualityObservationTimer)
- clearTimeout(this.runAutoQualitySchedulerTimer)
-
- clearInterval(this.torrentInfoInterval)
- clearInterval(this.autoQualityInterval)
-
- // Don't need to destroy renderer, video player will be destroyed
- this.flushVideoFile(this.currentVideoFile, false)
-
- this.destroyFakeRenderer()
- }
-
- getCurrentResolutionId () {
- return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1
- }
-
- updateVideoFile (
- videoFile?: VideoFile,
- options: {
- forcePlay?: boolean,
- seek?: number,
- delay?: number
- } = {},
- done: () => void = () => { /* empty */ }
- ) {
- // Automatically choose the adapted video file
- if (videoFile === undefined) {
- const savedAverageBandwidth = getAverageBandwidthInStore()
- videoFile = savedAverageBandwidth
- ? this.getAppropriateFile(savedAverageBandwidth)
- : this.pickAverageVideoFile()
- }
-
- // Don't add the same video file once again
- if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) {
- return
- }
-
- // Do not display error to user because we will have multiple fallback
- this.disableErrorDisplay()
-
- // Hack to "simulate" src link in video.js >= 6
- // Without this, we can't play the video after pausing it
- // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
- this.player.src = () => true
- const oldPlaybackRate = this.player.playbackRate()
-
- const previousVideoFile = this.currentVideoFile
- this.currentVideoFile = videoFile
-
- // Don't try on iOS that does not support MediaSource
- // Or don't use P2P if webtorrent is disabled
- if (this.isIOS() || this.playerRefusedP2P) {
- return this.fallbackToHttp(options, () => {
- this.player.playbackRate(oldPlaybackRate)
- return done()
- })
- }
-
- this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => {
- this.player.playbackRate(oldPlaybackRate)
- return done()
- })
-
- this.changeQuality()
- this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id })
- }
-
- updateResolution (resolutionId: number, delay = 0) {
- // Remember player state
- const currentTime = this.player.currentTime()
- const isPaused = this.player.paused()
-
- // Remove poster to have black background
- this.playerElement.poster = ''
-
- // Hide bigPlayButton
- if (!isPaused) {
- this.player.bigPlayButton.hide()
- }
-
- const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
- const options = {
- forcePlay: false,
- delay,
- seek: currentTime + (delay / 1000)
- }
- this.updateVideoFile(newVideoFile, options)
- }
-
- flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
- if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
- if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
-
- this.webtorrent.remove(videoFile.magnetUri)
- console.log('Removed ' + videoFile.magnetUri)
- }
- }
-
- enableAutoResolution () {
- this.autoResolution = true
- this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
- }
-
- disableAutoResolution (forbid = false) {
- if (forbid === true) this.autoResolutionPossible = false
-
- this.autoResolution = false
- this.trigger('autoResolutionChange', { possible: this.autoResolutionPossible })
- this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
- }
-
- getTorrent () {
- return this.torrent
- }
-
- private addTorrent (
- magnetOrTorrentUrl: string,
- previousVideoFile: VideoFile,
- options: PlayOptions,
- done: Function
- ) {
- console.log('Adding ' + magnetOrTorrentUrl + '.')
-
- const oldTorrent = this.torrent
- const torrentOptions = {
- store: (chunkLength: number, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), {
- max: 100
- })
- }
-
- this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => {
- console.log('Added ' + magnetOrTorrentUrl + '.')
-
- if (oldTorrent) {
- // Pause the old torrent
- this.stopTorrent(oldTorrent)
-
- // We use a fake renderer so we download correct pieces of the next file
- if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay)
- }
-
- // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution)
- this.addTorrentDelay = setTimeout(() => {
- // We don't need the fake renderer anymore
- this.destroyFakeRenderer()
-
- const paused = this.player.paused()
-
- this.flushVideoFile(previousVideoFile)
-
- // Update progress bar (just for the UI), do not wait rendering
- if (options.seek) this.player.currentTime(options.seek)
-
- const renderVideoOptions = { autoplay: false, controls: true }
- renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => {
- this.renderer = renderer
-
- if (err) return this.fallbackToHttp(options, done)
-
- return this.tryToPlay(err => {
- if (err) return done(err)
-
- if (options.seek) this.seek(options.seek)
- if (options.forcePlay === false && paused === true) this.player.pause()
-
- return done()
- })
- })
- }, options.delay || 0)
- })
-
- this.torrent.on('error', (err: any) => console.error(err))
-
- this.torrent.on('warning', (err: any) => {
- // We don't support HTTP tracker but we don't care -> we use the web socket tracker
- if (err.message.indexOf('Unsupported tracker protocol') !== -1) return
-
- // Users don't care about issues with WebRTC, but developers do so log it in the console
- if (err.message.indexOf('Ice connection failed') !== -1) {
- console.log(err)
- return
- }
-
- // Magnet hash is not up to date with the torrent file, add directly the torrent file
- if (err.message.indexOf('incorrect info hash') !== -1) {
- console.error('Incorrect info hash detected, falling back to torrent file.')
- const newOptions = { forcePlay: true, seek: options.seek }
- return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done)
- }
-
- // Remote instance is down
- if (err.message.indexOf('from xs param') !== -1) {
- this.handleError(err)
- }
-
- console.warn(err)
- })
- }
-
- private tryToPlay (done?: (err?: Error) => void) {
- if (!done) done = function () { /* empty */ }
-
- const playPromise = this.player.play()
- if (playPromise !== undefined) {
- return playPromise.then(done)
- .catch((err: Error) => {
- if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) {
- return
- }
-
- console.error(err)
- this.player.pause()
- this.player.posterImage.show()
- this.player.removeClass('vjs-has-autoplay')
- this.player.removeClass('vjs-has-big-play-button-clicked')
-
- return done()
- })
- }
-
- return done()
- }
-
- private seek (time: number) {
- this.player.currentTime(time)
- this.player.handleTechSeeked_()
- }
-
- private getAppropriateFile (averageDownloadSpeed?: number): VideoFile {
- if (this.videoFiles === undefined || this.videoFiles.length === 0) return undefined
- if (this.videoFiles.length === 1) return this.videoFiles[0]
-
- // Don't change the torrent is the play was ended
- if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile
-
- if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed()
-
- // Limit resolution according to player height
- const playerHeight = this.playerElement.offsetHeight as number
-
- // We take the first resolution just above the player height
- // Example: player height is 530px, we want the 720p file instead of 480p
- let maxResolution = this.videoFiles[0].resolution.id
- for (let i = this.videoFiles.length - 1; i >= 0; i--) {
- const resolutionId = this.videoFiles[i].resolution.id
- if (resolutionId >= playerHeight) {
- maxResolution = resolutionId
- break
- }
- }
-
- // Filter videos we can play according to our screen resolution and bandwidth
- const filteredFiles = this.videoFiles
- .filter(f => f.resolution.id <= maxResolution)
- .filter(f => {
- const fileBitrate = (f.size / this.videoDuration)
- let threshold = fileBitrate
-
- // If this is for a higher resolution or an initial load: add a margin
- if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) {
- threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100)
- }
-
- return averageDownloadSpeed > threshold
- })
-
- // If the download speed is too bad, return the lowest resolution we have
- if (filteredFiles.length === 0) return videoFileMinByResolution(this.videoFiles)
-
- return videoFileMaxByResolution(filteredFiles)
- }
-
- private getAndSaveActualDownloadSpeed () {
- const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0)
- const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length)
- if (lastDownloadSpeeds.length === 0) return -1
-
- const sum = lastDownloadSpeeds.reduce((a, b) => a + b)
- const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length)
-
- // Save the average bandwidth for future use
- saveAverageBandwidth(averageBandwidth)
-
- return averageBandwidth
- }
-
- private initializePlayer () {
- this.buildQualities()
-
- if (this.autoplay === true) {
- this.player.posterImage.hide()
-
- return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
- }
-
- // Proxy first play
- const oldPlay = this.player.play.bind(this.player)
- this.player.play = () => {
- this.player.addClass('vjs-has-big-play-button-clicked')
- this.player.play = oldPlay
-
- this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
- }
- }
-
- private runAutoQualityScheduler () {
- this.autoQualityInterval = setInterval(() => {
-
- // Not initialized or in HTTP fallback
- if (this.torrent === undefined || this.torrent === null) return
- if (this.autoResolution === false) return
- if (this.isAutoResolutionObservation === true) return
-
- const file = this.getAppropriateFile()
- let changeResolution = false
- let changeResolutionDelay = 0
-
- // Lower resolution
- if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) {
- console.log('Downgrading automatically the resolution to: %s', file.resolution.label)
- changeResolution = true
- } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution
- console.log('Upgrading automatically the resolution to: %s', file.resolution.label)
- changeResolution = true
- changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY
- }
-
- if (changeResolution === true) {
- this.updateResolution(file.resolution.id, changeResolutionDelay)
-
- // Wait some seconds in observation of our new resolution
- this.isAutoResolutionObservation = true
-
- this.qualityObservationTimer = setTimeout(() => {
- this.isAutoResolutionObservation = false
- }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME)
- }
- }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
- }
-
- private isPlayerWaiting () {
- return this.player && this.player.hasClass('vjs-waiting')
- }
-
- private runTorrentInfoScheduler () {
- this.torrentInfoInterval = setInterval(() => {
- // Not initialized yet
- if (this.torrent === undefined) return
-
- // Http fallback
- if (this.torrent === null) return this.player.trigger('p2pInfo', false)
-
- // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too
- if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed)
-
- return this.player.trigger('p2pInfo', {
- p2p: {
- downloadSpeed: this.torrent.downloadSpeed,
- numPeers: this.torrent.numPeers,
- uploadSpeed: this.torrent.uploadSpeed,
- downloaded: this.torrent.downloaded,
- uploaded: this.torrent.uploaded
- }
- } as PlayerNetworkInfo)
- }, this.CONSTANTS.INFO_SCHEDULER)
- }
-
- private fallbackToHttp (options: PlayOptions, done?: Function) {
- const paused = this.player.paused()
-
- this.disableAutoResolution(true)
-
- this.flushVideoFile(this.currentVideoFile, true)
- this.torrent = null
-
- // Enable error display now this is our last fallback
- this.player.one('error', () => this.enableErrorDisplay())
-
- const httpUrl = this.currentVideoFile.fileUrl
- this.player.src = this.savePlayerSrcFunction
- this.player.src(httpUrl)
-
- this.changeQuality()
-
- // We changed the source, so reinit captions
- this.player.trigger('sourcechange')
-
- return this.tryToPlay(err => {
- if (err && done) return done(err)
-
- if (options.seek) this.seek(options.seek)
- if (options.forcePlay === false && paused === true) this.player.pause()
-
- if (done) return done()
- })
- }
-
- private handleError (err: Error | string) {
- return this.player.trigger('customError', { err })
- }
-
- private enableErrorDisplay () {
- this.player.addClass('vjs-error-display-enabled')
- }
-
- private disableErrorDisplay () {
- this.player.removeClass('vjs-error-display-enabled')
- }
-
- private isIOS () {
- return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform)
- }
-
- private pickAverageVideoFile () {
- if (this.videoFiles.length === 1) return this.videoFiles[0]
-
- return this.videoFiles[Math.floor(this.videoFiles.length / 2)]
- }
-
- private stopTorrent (torrent: WebTorrent.Torrent) {
- torrent.pause()
- // Pause does not remove actual peers (in particular the webseed peer)
- torrent.removePeer(torrent[ 'ws' ])
- }
-
- private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) {
- this.destroyingFakeRenderer = false
-
- const fakeVideoElem = document.createElement('video')
- renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
- this.fakeRenderer = renderer
-
- // The renderer returns an error when we destroy it, so skip them
- if (this.destroyingFakeRenderer === false && err) {
- console.error('Cannot render new torrent in fake video element.', err)
- }
-
- // Load the future file at the correct time (in delay MS - 2 seconds)
- fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000)
- })
- }
-
- private destroyFakeRenderer () {
- if (this.fakeRenderer) {
- this.destroyingFakeRenderer = true
-
- if (this.fakeRenderer.destroy) {
- try {
- this.fakeRenderer.destroy()
- } catch (err) {
- console.log('Cannot destroy correctly fake renderer.', err)
- }
- }
- this.fakeRenderer = undefined
- }
- }
-
- private buildQualities () {
- const qualityLevelsPayload = []
-
- for (const file of this.videoFiles) {
- const representation = {
- id: file.resolution.id,
- label: this.buildQualityLabel(file),
- height: file.resolution.id,
- _enabled: true
- }
-
- this.player.qualityLevels().addQualityLevel(representation)
-
- qualityLevelsPayload.push({
- id: representation.id,
- label: representation.label,
- selected: false
- })
- }
-
- const payload: LoadedQualityData = {
- qualitySwitchCallback: (d: any) => this.qualitySwitchCallback(d),
- qualityData: {
- video: qualityLevelsPayload
- }
- }
- this.player.tech_.trigger('loadedqualitydata', payload)
- }
-
- private buildQualityLabel (file: VideoFile) {
- let label = file.resolution.label
-
- if (file.fps && file.fps >= 50) {
- label += file.fps
- }
-
- return label
- }
-
- private qualitySwitchCallback (id: number) {
- if (id === -1) {
- if (this.autoResolutionPossible === true) this.enableAutoResolution()
- return
- }
-
- this.disableAutoResolution()
- this.updateResolution(id)
- }
-
- private changeQuality () {
- const resolutionId = this.currentVideoFile.resolution.id
- const qualityLevels = this.player.qualityLevels()
-
- if (resolutionId === -1) {
- qualityLevels.selectedIndex = -1
- return
- }
-
- for (let i = 0; i < qualityLevels; i++) {
- const q = this.player.qualityLevels[i]
- if (q.height === resolutionId) qualityLevels.selectedIndex = i
- }
- }
-}
-
-videojs.registerPlugin('webtorrent', WebTorrentPlugin)
-export { WebTorrentPlugin }
--- /dev/null
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import * as videojs from 'video.js'
+
+import * as WebTorrent from 'webtorrent'
+import { VideoFile } from '../../../../../shared/models/videos/video.model'
+import { renderVideo } from './video-renderer'
+import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings'
+import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../utils'
+import { PeertubeChunkStore } from './peertube-chunk-store'
+import {
+ getAverageBandwidthInStore,
+ getStoredMute,
+ getStoredVolume,
+ getStoredWebTorrentEnabled,
+ saveAverageBandwidth
+} from '../peertube-player-local-storage'
+
+const CacheChunkStore = require('cache-chunk-store')
+
+type PlayOptions = {
+ forcePlay?: boolean,
+ seek?: number,
+ delay?: number
+}
+
+const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
+class WebTorrentPlugin extends Plugin {
+ private readonly playerElement: HTMLVideoElement
+
+ private readonly autoplay: boolean = false
+ private readonly startTime: number = 0
+ private readonly savePlayerSrcFunction: Function
+ private readonly videoFiles: VideoFile[]
+ private readonly videoDuration: number
+ private readonly CONSTANTS = {
+ INFO_SCHEDULER: 1000, // Don't change this
+ AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds
+ AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
+ AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
+ AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
+ BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth
+ }
+
+ private readonly webtorrent = new WebTorrent({
+ tracker: {
+ rtcConfig: getRtcConfig()
+ },
+ dht: false
+ })
+
+ private player: any
+ private currentVideoFile: VideoFile
+ private torrent: WebTorrent.Torrent
+
+ private renderer: any
+ private fakeRenderer: any
+ private destroyingFakeRenderer = false
+
+ private autoResolution = true
+ private autoResolutionPossible = true
+ private isAutoResolutionObservation = false
+ private playerRefusedP2P = false
+
+ private torrentInfoInterval: any
+ private autoQualityInterval: any
+ private addTorrentDelay: any
+ private qualityObservationTimer: any
+ private runAutoQualitySchedulerTimer: any
+
+ private downloadSpeeds: number[] = []
+
+ constructor (player: videojs.Player, options: WebtorrentPluginOptions) {
+ super(player, options)
+
+ // Disable auto play on iOS
+ this.autoplay = options.autoplay && this.isIOS() === false
+ this.playerRefusedP2P = !getStoredWebTorrentEnabled()
+
+ this.videoFiles = options.videoFiles
+ this.videoDuration = options.videoDuration
+
+ this.savePlayerSrcFunction = this.player.src
+ this.playerElement = options.playerElement
+
+ this.player.ready(() => {
+ const playerOptions = this.player.options_
+
+ const volume = getStoredVolume()
+ if (volume !== undefined) this.player.volume(volume)
+
+ const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
+ if (muted !== undefined) this.player.muted(muted)
+
+ this.player.duration(options.videoDuration)
+
+ this.initializePlayer()
+ this.runTorrentInfoScheduler()
+
+ this.player.one('play', () => {
+ // Don't run immediately scheduler, wait some seconds the TCP connections are made
+ this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
+ })
+ })
+ }
+
+ dispose () {
+ clearTimeout(this.addTorrentDelay)
+ clearTimeout(this.qualityObservationTimer)
+ clearTimeout(this.runAutoQualitySchedulerTimer)
+
+ clearInterval(this.torrentInfoInterval)
+ clearInterval(this.autoQualityInterval)
+
+ // Don't need to destroy renderer, video player will be destroyed
+ this.flushVideoFile(this.currentVideoFile, false)
+
+ this.destroyFakeRenderer()
+ }
+
+ getCurrentResolutionId () {
+ return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1
+ }
+
+ updateVideoFile (
+ videoFile?: VideoFile,
+ options: {
+ forcePlay?: boolean,
+ seek?: number,
+ delay?: number
+ } = {},
+ done: () => void = () => { /* empty */ }
+ ) {
+ // Automatically choose the adapted video file
+ if (videoFile === undefined) {
+ const savedAverageBandwidth = getAverageBandwidthInStore()
+ videoFile = savedAverageBandwidth
+ ? this.getAppropriateFile(savedAverageBandwidth)
+ : this.pickAverageVideoFile()
+ }
+
+ // Don't add the same video file once again
+ if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) {
+ return
+ }
+
+ // Do not display error to user because we will have multiple fallback
+ this.disableErrorDisplay()
+
+ // Hack to "simulate" src link in video.js >= 6
+ // Without this, we can't play the video after pausing it
+ // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
+ this.player.src = () => true
+ const oldPlaybackRate = this.player.playbackRate()
+
+ const previousVideoFile = this.currentVideoFile
+ this.currentVideoFile = videoFile
+
+ // Don't try on iOS that does not support MediaSource
+ // Or don't use P2P if webtorrent is disabled
+ if (this.isIOS() || this.playerRefusedP2P) {
+ return this.fallbackToHttp(options, () => {
+ this.player.playbackRate(oldPlaybackRate)
+ return done()
+ })
+ }
+
+ this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => {
+ this.player.playbackRate(oldPlaybackRate)
+ return done()
+ })
+
+ this.changeQuality()
+ this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id })
+ }
+
+ updateResolution (resolutionId: number, delay = 0) {
+ // Remember player state
+ const currentTime = this.player.currentTime()
+ const isPaused = this.player.paused()
+
+ // Remove poster to have black background
+ this.playerElement.poster = ''
+
+ // Hide bigPlayButton
+ if (!isPaused) {
+ this.player.bigPlayButton.hide()
+ }
+
+ const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
+ const options = {
+ forcePlay: false,
+ delay,
+ seek: currentTime + (delay / 1000)
+ }
+ this.updateVideoFile(newVideoFile, options)
+ }
+
+ flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
+ if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
+ if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
+
+ this.webtorrent.remove(videoFile.magnetUri)
+ console.log('Removed ' + videoFile.magnetUri)
+ }
+ }
+
+ enableAutoResolution () {
+ this.autoResolution = true
+ this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
+ }
+
+ disableAutoResolution (forbid = false) {
+ if (forbid === true) this.autoResolutionPossible = false
+
+ this.autoResolution = false
+ this.trigger('autoResolutionChange', { possible: this.autoResolutionPossible })
+ this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
+ }
+
+ getTorrent () {
+ return this.torrent
+ }
+
+ private addTorrent (
+ magnetOrTorrentUrl: string,
+ previousVideoFile: VideoFile,
+ options: PlayOptions,
+ done: Function
+ ) {
+ console.log('Adding ' + magnetOrTorrentUrl + '.')
+
+ const oldTorrent = this.torrent
+ const torrentOptions = {
+ store: (chunkLength: number, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), {
+ max: 100
+ })
+ }
+
+ this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => {
+ console.log('Added ' + magnetOrTorrentUrl + '.')
+
+ if (oldTorrent) {
+ // Pause the old torrent
+ this.stopTorrent(oldTorrent)
+
+ // We use a fake renderer so we download correct pieces of the next file
+ if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay)
+ }
+
+ // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution)
+ this.addTorrentDelay = setTimeout(() => {
+ // We don't need the fake renderer anymore
+ this.destroyFakeRenderer()
+
+ const paused = this.player.paused()
+
+ this.flushVideoFile(previousVideoFile)
+
+ // Update progress bar (just for the UI), do not wait rendering
+ if (options.seek) this.player.currentTime(options.seek)
+
+ const renderVideoOptions = { autoplay: false, controls: true }
+ renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => {
+ this.renderer = renderer
+
+ if (err) return this.fallbackToHttp(options, done)
+
+ return this.tryToPlay(err => {
+ if (err) return done(err)
+
+ if (options.seek) this.seek(options.seek)
+ if (options.forcePlay === false && paused === true) this.player.pause()
+
+ return done()
+ })
+ })
+ }, options.delay || 0)
+ })
+
+ this.torrent.on('error', (err: any) => console.error(err))
+
+ this.torrent.on('warning', (err: any) => {
+ // We don't support HTTP tracker but we don't care -> we use the web socket tracker
+ if (err.message.indexOf('Unsupported tracker protocol') !== -1) return
+
+ // Users don't care about issues with WebRTC, but developers do so log it in the console
+ if (err.message.indexOf('Ice connection failed') !== -1) {
+ console.log(err)
+ return
+ }
+
+ // Magnet hash is not up to date with the torrent file, add directly the torrent file
+ if (err.message.indexOf('incorrect info hash') !== -1) {
+ console.error('Incorrect info hash detected, falling back to torrent file.')
+ const newOptions = { forcePlay: true, seek: options.seek }
+ return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done)
+ }
+
+ // Remote instance is down
+ if (err.message.indexOf('from xs param') !== -1) {
+ this.handleError(err)
+ }
+
+ console.warn(err)
+ })
+ }
+
+ private tryToPlay (done?: (err?: Error) => void) {
+ if (!done) done = function () { /* empty */ }
+
+ const playPromise = this.player.play()
+ if (playPromise !== undefined) {
+ return playPromise.then(done)
+ .catch((err: Error) => {
+ if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) {
+ return
+ }
+
+ console.error(err)
+ this.player.pause()
+ this.player.posterImage.show()
+ this.player.removeClass('vjs-has-autoplay')
+ this.player.removeClass('vjs-has-big-play-button-clicked')
+
+ return done()
+ })
+ }
+
+ return done()
+ }
+
+ private seek (time: number) {
+ this.player.currentTime(time)
+ this.player.handleTechSeeked_()
+ }
+
+ private getAppropriateFile (averageDownloadSpeed?: number): VideoFile {
+ if (this.videoFiles === undefined || this.videoFiles.length === 0) return undefined
+ if (this.videoFiles.length === 1) return this.videoFiles[0]
+
+ // Don't change the torrent is the play was ended
+ if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile
+
+ if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed()
+
+ // Limit resolution according to player height
+ const playerHeight = this.playerElement.offsetHeight as number
+
+ // We take the first resolution just above the player height
+ // Example: player height is 530px, we want the 720p file instead of 480p
+ let maxResolution = this.videoFiles[0].resolution.id
+ for (let i = this.videoFiles.length - 1; i >= 0; i--) {
+ const resolutionId = this.videoFiles[i].resolution.id
+ if (resolutionId >= playerHeight) {
+ maxResolution = resolutionId
+ break
+ }
+ }
+
+ // Filter videos we can play according to our screen resolution and bandwidth
+ const filteredFiles = this.videoFiles
+ .filter(f => f.resolution.id <= maxResolution)
+ .filter(f => {
+ const fileBitrate = (f.size / this.videoDuration)
+ let threshold = fileBitrate
+
+ // If this is for a higher resolution or an initial load: add a margin
+ if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) {
+ threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100)
+ }
+
+ return averageDownloadSpeed > threshold
+ })
+
+ // If the download speed is too bad, return the lowest resolution we have
+ if (filteredFiles.length === 0) return videoFileMinByResolution(this.videoFiles)
+
+ return videoFileMaxByResolution(filteredFiles)
+ }
+
+ private getAndSaveActualDownloadSpeed () {
+ const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0)
+ const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length)
+ if (lastDownloadSpeeds.length === 0) return -1
+
+ const sum = lastDownloadSpeeds.reduce((a, b) => a + b)
+ const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length)
+
+ // Save the average bandwidth for future use
+ saveAverageBandwidth(averageBandwidth)
+
+ return averageBandwidth
+ }
+
+ private initializePlayer () {
+ this.buildQualities()
+
+ if (this.autoplay === true) {
+ this.player.posterImage.hide()
+
+ return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
+ }
+
+ // Proxy first play
+ const oldPlay = this.player.play.bind(this.player)
+ this.player.play = () => {
+ this.player.addClass('vjs-has-big-play-button-clicked')
+ this.player.play = oldPlay
+
+ this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
+ }
+ }
+
+ private runAutoQualityScheduler () {
+ this.autoQualityInterval = setInterval(() => {
+
+ // Not initialized or in HTTP fallback
+ if (this.torrent === undefined || this.torrent === null) return
+ if (this.autoResolution === false) return
+ if (this.isAutoResolutionObservation === true) return
+
+ const file = this.getAppropriateFile()
+ let changeResolution = false
+ let changeResolutionDelay = 0
+
+ // Lower resolution
+ if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) {
+ console.log('Downgrading automatically the resolution to: %s', file.resolution.label)
+ changeResolution = true
+ } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution
+ console.log('Upgrading automatically the resolution to: %s', file.resolution.label)
+ changeResolution = true
+ changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY
+ }
+
+ if (changeResolution === true) {
+ this.updateResolution(file.resolution.id, changeResolutionDelay)
+
+ // Wait some seconds in observation of our new resolution
+ this.isAutoResolutionObservation = true
+
+ this.qualityObservationTimer = setTimeout(() => {
+ this.isAutoResolutionObservation = false
+ }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME)
+ }
+ }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
+ }
+
+ private isPlayerWaiting () {
+ return this.player && this.player.hasClass('vjs-waiting')
+ }
+
+ private runTorrentInfoScheduler () {
+ this.torrentInfoInterval = setInterval(() => {
+ // Not initialized yet
+ if (this.torrent === undefined) return
+
+ // Http fallback
+ if (this.torrent === null) return this.player.trigger('p2pInfo', false)
+
+ // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too
+ if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed)
+
+ return this.player.trigger('p2pInfo', {
+ http: {
+ downloadSpeed: 0,
+ uploadSpeed: 0,
+ downloaded: 0,
+ uploaded: 0
+ },
+ p2p: {
+ downloadSpeed: this.torrent.downloadSpeed,
+ numPeers: this.torrent.numPeers,
+ uploadSpeed: this.torrent.uploadSpeed,
+ downloaded: this.torrent.downloaded,
+ uploaded: this.torrent.uploaded
+ }
+ } as PlayerNetworkInfo)
+ }, this.CONSTANTS.INFO_SCHEDULER)
+ }
+
+ private fallbackToHttp (options: PlayOptions, done?: Function) {
+ const paused = this.player.paused()
+
+ this.disableAutoResolution(true)
+
+ this.flushVideoFile(this.currentVideoFile, true)
+ this.torrent = null
+
+ // Enable error display now this is our last fallback
+ this.player.one('error', () => this.enableErrorDisplay())
+
+ const httpUrl = this.currentVideoFile.fileUrl
+ this.player.src = this.savePlayerSrcFunction
+ this.player.src(httpUrl)
+
+ this.changeQuality()
+
+ // We changed the source, so reinit captions
+ this.player.trigger('sourcechange')
+
+ return this.tryToPlay(err => {
+ if (err && done) return done(err)
+
+ if (options.seek) this.seek(options.seek)
+ if (options.forcePlay === false && paused === true) this.player.pause()
+
+ if (done) return done()
+ })
+ }
+
+ private handleError (err: Error | string) {
+ return this.player.trigger('customError', { err })
+ }
+
+ private enableErrorDisplay () {
+ this.player.addClass('vjs-error-display-enabled')
+ }
+
+ private disableErrorDisplay () {
+ this.player.removeClass('vjs-error-display-enabled')
+ }
+
+ private isIOS () {
+ return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform)
+ }
+
+ private pickAverageVideoFile () {
+ if (this.videoFiles.length === 1) return this.videoFiles[0]
+
+ return this.videoFiles[Math.floor(this.videoFiles.length / 2)]
+ }
+
+ private stopTorrent (torrent: WebTorrent.Torrent) {
+ torrent.pause()
+ // Pause does not remove actual peers (in particular the webseed peer)
+ torrent.removePeer(torrent[ 'ws' ])
+ }
+
+ private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) {
+ this.destroyingFakeRenderer = false
+
+ const fakeVideoElem = document.createElement('video')
+ renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
+ this.fakeRenderer = renderer
+
+ // The renderer returns an error when we destroy it, so skip them
+ if (this.destroyingFakeRenderer === false && err) {
+ console.error('Cannot render new torrent in fake video element.', err)
+ }
+
+ // Load the future file at the correct time (in delay MS - 2 seconds)
+ fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000)
+ })
+ }
+
+ private destroyFakeRenderer () {
+ if (this.fakeRenderer) {
+ this.destroyingFakeRenderer = true
+
+ if (this.fakeRenderer.destroy) {
+ try {
+ this.fakeRenderer.destroy()
+ } catch (err) {
+ console.log('Cannot destroy correctly fake renderer.', err)
+ }
+ }
+ this.fakeRenderer = undefined
+ }
+ }
+
+ private buildQualities () {
+ const qualityLevelsPayload = []
+
+ for (const file of this.videoFiles) {
+ const representation = {
+ id: file.resolution.id,
+ label: this.buildQualityLabel(file),
+ height: file.resolution.id,
+ _enabled: true
+ }
+
+ this.player.qualityLevels().addQualityLevel(representation)
+
+ qualityLevelsPayload.push({
+ id: representation.id,
+ label: representation.label,
+ selected: false
+ })
+ }
+
+ const payload: LoadedQualityData = {
+ qualitySwitchCallback: (d: any) => this.qualitySwitchCallback(d),
+ qualityData: {
+ video: qualityLevelsPayload
+ }
+ }
+ this.player.tech_.trigger('loadedqualitydata', payload)
+ }
+
+ private buildQualityLabel (file: VideoFile) {
+ let label = file.resolution.label
+
+ if (file.fps && file.fps >= 50) {
+ label += file.fps
+ }
+
+ return label
+ }
+
+ private qualitySwitchCallback (id: number) {
+ if (id === -1) {
+ if (this.autoResolutionPossible === true) this.enableAutoResolution()
+ return
+ }
+
+ this.disableAutoResolution()
+ this.updateResolution(id)
+ }
+
+ private changeQuality () {
+ const resolutionId = this.currentVideoFile.resolution.id
+ const qualityLevels = this.player.qualityLevels()
+
+ if (resolutionId === -1) {
+ qualityLevels.selectedIndex = -1
+ return
+ }
+
+ for (let i = 0; i < qualityLevels; i++) {
+ const q = this.player.qualityLevels[i]
+ if (q.height === resolutionId) qualityLevels.selectedIndex = i
+ }
+ }
+}
+
+videojs.registerPlugin('webtorrent', WebTorrentPlugin)
+export { WebTorrentPlugin }
import { PeerTubeResolution } from '../player/definitions'
import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings'
import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model'
-import { PeertubePlayerManager, PeertubePlayerManagerOptions, PlayerMode } from '../../assets/player/peertube-player-manager'
+import {
+ P2PMediaLoaderOptions,
+ PeertubePlayerManager,
+ PeertubePlayerManagerOptions,
+ PlayerMode
+} from '../../assets/player/peertube-player-manager'
+import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type'
/**
* Embed API exposes control of the embed player to the outside world via
}
if (this.mode === 'p2p-media-loader') {
+ const hlsPlaylist = videoInfo.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
+
Object.assign(options, {
p2pMediaLoader: {
- // playlistUrl: 'https://akamai-axtest.akamaized.net/routes/lapd-v1-acceptance/www_c4/Manifest.m3u8'
- // playlistUrl: 'https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8'
- // trackerAnnounce: [ window.location.origin.replace(/^http/, 'ws') + '/tracker/socket' ],
- playlistUrl: 'https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8'
- }
+ playlistUrl: hlsPlaylist.playlistUrl,
+ segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
+ redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
+ trackerAnnounce: videoInfo.trackerUrls,
+ videoFiles: videoInfo.files
+ } as P2PMediaLoaderOptions
})
} else {
Object.assign(options, {
dependencies:
ms "^2.1.1"
+debug@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
+ integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
+ dependencies:
+ ms "^2.1.1"
+
decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.2.0.tgz#c8e0785fd17f741f4408b49466889274a9e36447"
integrity sha512-LVHw0U6IPJjwk9i9f7Xe26NqaUHTNlIt4SSWoEfYFROeVKHN6MIjOhbRheI3dg8Jbq5WCuMFQ0QU3EgZpmzFPg==
-m3u8-parser@^4.2.0:
+m3u8-parser@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.3.0.tgz#4b4e988f87b6d8b2401d209a1d17798285a9da04"
integrity sha512-bVbjuBMoVIgFL1vpXVIxjeaoB5TPDJRb0m5qiTdM738SGqv/LAmsnVVPlKjM4fulm/rr1XZsKM+owHm+zvqxYA==
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1"
integrity sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==
-p2p-media-loader-core@^0.3.0:
- version "0.3.0"
- resolved "https://registry.yarnpkg.com/p2p-media-loader-core/-/p2p-media-loader-core-0.3.0.tgz#75687d7d7bee835d5c6c2f17d346add2dbe43b83"
- integrity sha512-WKB9ONdA0kDQHXr6nixIL8t0UZuTD9Pqi/BIuaTiPUGDwYXUS/Mf5YynLAUupniLkIaDYD7/jmSLWqpZUDsAyw==
+p2p-media-loader-core@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/p2p-media-loader-core/-/p2p-media-loader-core-0.4.0.tgz#767d56785545bc9c0d8c1a04eb7b67a33e40d0c8"
+ integrity sha512-llcFqEDs19o916g2OSIPHPjZweO5caHUm/7P18Qu+qb3swYQYSPNwMLoHnpXROHiH5I+00K8w5enz31oUwiCgA==
dependencies:
bittorrent-tracker "^9.10.1"
- debug "^4.1.0"
+ debug "^4.1.1"
events "^3.0.0"
get-browser-rtc "^1.0.2"
sha.js "^2.4.11"
-p2p-media-loader-hlsjs@^0.3.0:
- version "0.3.0"
- resolved "https://registry.yarnpkg.com/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-0.3.0.tgz#4ee15d4d1a23aa0322a5be2bc6c329b6c913028d"
- integrity sha512-U7PzMG5X7CVQ15OtMPRQjW68Msu0fuw8Pp0PRznX5uK0p26tSYMT/ZYCNeYCoDg3wGgJHM+327ed3M7TRJ4lcw==
+p2p-media-loader-hlsjs@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-0.4.0.tgz#1b90c88580503d4c3d8017c813abe41803b613ed"
+ integrity sha512-IWRs/aGasKD//+dtQkYWAjD/cQx3LMaLkMn0EzLhLpeBj4SLNjlbwOPlbx36M4i39X04Y3WZe9YUeIciId3G5Q==
dependencies:
events "^3.0.0"
- m3u8-parser "^4.2.0"
- p2p-media-loader-core "^0.3.0"
+ m3u8-parser "^4.3.0"
+ p2p-media-loader-core "^0.4.0"
package-json-versionify@^1.0.2:
version "1.0.4"
tmp: 'storage/tmp/' # Used to download data (imports etc), store uploaded files before processing...
avatars: 'storage/avatars/'
videos: 'storage/videos/'
+ playlists: 'storage/playlists/'
redundancy: 'storage/redundancy/'
logs: 'storage/logs/'
previews: 'storage/previews/'
480p: false
720p: false
1080p: false
+ # /!\ EXPERIMENTAL /!\
+ # Generate HLS playlist/segments. Better playback than with WebTorrent:
+ # * Resolution change is smoother
+ # * Faster playback in particular with long videos
+ # * More stable playback (less bugs/infinite loading)
+ # /!\ Multiply videos storage by two /!\
+ hls:
+ enabled: false
import:
# Add ability for your users to import remote videos (from YouTube, torrent...)
tmp: '/var/www/peertube/storage/tmp/' # Used to download data (imports etc), store uploaded files before processing...
avatars: '/var/www/peertube/storage/avatars/'
videos: '/var/www/peertube/storage/videos/'
+ playlists: '/var/www/peertube/storage/playlists/'
redundancy: '/var/www/peertube/storage/videos/'
logs: '/var/www/peertube/storage/logs/'
previews: '/var/www/peertube/storage/previews/'
480p: false
720p: false
1080p: false
+ # /!\ EXPERIMENTAL /!\
+ # Generate HLS playlist/segments. Better playback than with WebTorrent:
+ # * Resolution change is smoother
+ # * Faster playback in particular with long videos
+ # * More stable playback (less bugs/infinite loading)
+ # /!\ Multiply videos storage by two /!\
+ hls:
+ enabled: false
import:
# Add ability for your users to import remote videos (from YouTube, torrent...)
tmp: 'test1/tmp/'
avatars: 'test1/avatars/'
videos: 'test1/videos/'
+ playlists: 'test1/playlists/'
redundancy: 'test1/redundancy/'
logs: 'test1/logs/'
previews: 'test1/previews/'
tmp: 'test2/tmp/'
avatars: 'test2/avatars/'
videos: 'test2/videos/'
+ playlists: 'test2/playlists/'
redundancy: 'test2/redundancy/'
logs: 'test2/logs/'
previews: 'test2/previews/'
tmp: 'test3/tmp/'
avatars: 'test3/avatars/'
videos: 'test3/videos/'
+ playlists: 'test3/playlists/'
redundancy: 'test3/redundancy/'
logs: 'test3/logs/'
previews: 'test3/previews/'
tmp: 'test4/tmp/'
avatars: 'test4/avatars/'
videos: 'test4/videos/'
+ playlists: 'test4/playlists/'
redundancy: 'test4/redundancy/'
logs: 'test4/logs/'
previews: 'test4/previews/'
tmp: 'test5/tmp/'
avatars: 'test5/avatars/'
videos: 'test5/videos/'
+ playlists: 'test5/playlists/'
redundancy: 'test5/redundancy/'
logs: 'test5/logs/'
previews: 'test5/previews/'
tmp: 'test6/tmp/'
avatars: 'test6/avatars/'
videos: 'test6/videos/'
+ playlists: 'test6/playlists/'
redundancy: 'test6/redundancy/'
logs: 'test6/logs/'
previews: 'test6/previews/'
480p: true
720p: true
1080p: true
+ hls:
+ enabled: true
import:
videos:
"fluent-ffmpeg": "^2.1.0",
"fs-extra": "^7.0.0",
"helmet": "^3.12.1",
+ "hlsdownloader": "https://github.com/Chocobozzz/hlsdownloader#build",
"http-signature": "^1.2.0",
"ip-anonymize": "^0.0.6",
"ipaddr.js": "1.8.1",
'Speed': 'Speed',
'Subtitles/CC': 'Subtitles/CC',
'peers': 'peers',
+ 'peer': 'peer',
'Go to the video page': 'Go to the video page',
'Settings': 'Settings',
'Uses P2P, others may know you are watching this video.': 'Uses P2P, others may know you are watching this video.',
'Copy the video URL': 'Copy the video URL',
'Copy the video URL at the current time': 'Copy the video URL at the current time',
- 'Copy embed code': 'Copy embed code'
+ 'Copy embed code': 'Copy embed code',
+ 'Total downloaded: ': 'Total downloaded: ',
+ 'Total uploaded: ': 'Total uploaded: '
}
const playerTranslations = {
target: join(__dirname, '../../../client/src/locale/source/player_en_US.xml'),
import { getServerActor } from '../server/helpers/utils'
import { AccountModel } from '../server/models/account/account'
import { VideoChannelModel } from '../server/models/video/video-channel'
+import { VideoStreamingPlaylistModel } from '../server/models/video/video-streaming-playlist'
run()
.then(() => process.exit(0))
console.log('Updating video and torrent files.')
- const videos = await VideoModel.list()
+ const videos = await VideoModel.listLocal()
for (const video of videos) {
- if (video.isOwned() === false) continue
-
- console.log('Updated video ' + video.uuid)
+ console.log('Updating video ' + video.uuid)
video.url = getVideoActivityPubUrl(video)
await video.save()
console.log('Updating torrent file %s of video %s.', file.resolution, video.uuid)
await video.createTorrentAndSetInfoHash(file)
}
+
+ for (const playlist of video.VideoStreamingPlaylists) {
+ playlist.playlistUrl = CONFIG.WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
+ playlist.segmentsSha256Url = CONFIG.WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid)
+
+ await playlist.save()
+ }
}
}
getVideoSharesActivityPubUrl
} from '../../lib/activitypub'
import { VideoCaptionModel } from '../../models/video/video-caption'
-import { videoRedundancyGetValidator } from '../../middlewares/validators/redundancy'
+import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator } from '../../middlewares/validators/redundancy'
import { getServerActor } from '../../helpers/utils'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
activityPubClientRouter.get('/videos/watch/:id',
executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS))),
- executeIfActivityPub(asyncMiddleware(videosGetValidator)),
+ executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video-with-rights'))),
executeIfActivityPub(asyncMiddleware(videoController))
)
activityPubClientRouter.get('/videos/watch/:id/activity',
- executeIfActivityPub(asyncMiddleware(videosGetValidator)),
+ executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video-with-rights'))),
executeIfActivityPub(asyncMiddleware(videoController))
)
activityPubClientRouter.get('/videos/watch/:id/announces',
)
activityPubClientRouter.get('/redundancy/videos/:videoId/:resolution([0-9]+)(-:fps([0-9]+))?',
- executeIfActivityPub(asyncMiddleware(videoRedundancyGetValidator)),
+ executeIfActivityPub(asyncMiddleware(videoFileRedundancyGetValidator)),
+ executeIfActivityPub(asyncMiddleware(videoRedundancyController))
+)
+activityPubClientRouter.get('/redundancy/video-playlists/:streamingPlaylistType/:videoId',
+ executeIfActivityPub(asyncMiddleware(videoPlaylistRedundancyGetValidator)),
executeIfActivityPub(asyncMiddleware(videoRedundancyController))
)
}
async function videoController (req: express.Request, res: express.Response) {
- const video: VideoModel = res.locals.video
+ // We need more attributes
+ const video: VideoModel = await VideoModel.loadForGetAPI(res.locals.video.id)
if (video.url.startsWith(CONFIG.WEBSERVER.URL) === false) return res.redirect(video.url)
import * as express from 'express'
-import { omit, snakeCase } from 'lodash'
+import { snakeCase } from 'lodash'
import { ServerConfig, UserRight } from '../../../shared'
import { About } from '../../../shared/models/server/about.model'
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
},
transcoding: {
+ hls: {
+ enabled: CONFIG.TRANSCODING.HLS.ENABLED
+ },
enabledResolutions
},
import: {
'480p': CONFIG.TRANSCODING.RESOLUTIONS[ '480p' ],
'720p': CONFIG.TRANSCODING.RESOLUTIONS[ '720p' ],
'1080p': CONFIG.TRANSCODING.RESOLUTIONS[ '1080p' ]
+ },
+ hls: {
+ enabled: CONFIG.TRANSCODING.HLS.ENABLED
}
},
import: {
setDefaultPagination,
setDefaultSort,
videosAddValidator,
+ videosCustomGetValidator,
videosGetValidator,
videosRemoveValidator,
videosSortValidator,
)
videosRouter.get('/:id',
optionalAuthenticate,
- asyncMiddleware(videosGetValidator),
+ asyncMiddleware(videosCustomGetValidator('only-video-with-rights')),
asyncMiddleware(checkVideoFollowConstraints),
- getVideo
+ asyncMiddleware(getVideo)
)
videosRouter.post('/:id/views',
asyncMiddleware(videosGetValidator),
return res.type('json').status(204).end()
}
-function getVideo (req: express.Request, res: express.Response) {
- const videoInstance = res.locals.video
+async function getVideo (req: express.Request, res: express.Response) {
+ // We need more attributes
+ const userId: number = res.locals.oauth ? res.locals.oauth.token.User.id : null
+ const video: VideoModel = await VideoModel.loadForGetAPI(res.locals.video.id, undefined, userId)
- if (videoInstance.isOutdated()) {
- JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: videoInstance.url } })
- .catch(err => logger.error('Cannot create AP refresher job for video %s.', videoInstance.url, { err }))
+ if (video.isOutdated()) {
+ JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
+ .catch(err => logger.error('Cannot create AP refresher job for video %s.', video.url, { err }))
}
- return res.json(videoInstance.toFormattedDetailsJSON())
+ return res.json(video.toFormattedDetailsJSON())
}
async function viewVideo (req: express.Request, res: express.Response) {
import * as cors from 'cors'
import * as express from 'express'
-import { CONFIG, ROUTE_CACHE_LIFETIME, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers'
+import { CONFIG, HLS_PLAYLIST_DIRECTORY, ROUTE_CACHE_LIFETIME, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers'
import { VideosPreviewCache } from '../lib/cache'
import { cacheRoute } from '../middlewares/cache'
import { asyncMiddleware, videosGetValidator } from '../middlewares'
asyncMiddleware(downloadVideoFile)
)
+// HLS
+staticRouter.use(
+ STATIC_PATHS.PLAYLISTS.HLS,
+ cors(),
+ express.static(HLS_PLAYLIST_DIRECTORY, { fallthrough: false }) // 404 if the file does not exist
+)
+
// Thumbnails path for express
const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR
staticRouter.use(
import { CONFIG, TRACKER_RATE_LIMITS } from '../initializers/constants'
import { VideoFileModel } from '../models/video/video-file'
import { parse } from 'url'
+import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
const TrackerServer = bitTorrentTracker.Server
udp: false,
ws: false,
dht: false,
- filter: function (infoHash, params, cb) {
+ filter: async function (infoHash, params, cb) {
let ip: string
if (params.type === 'ws') {
const key = ip + '-' + infoHash
- peersIps[ip] = peersIps[ip] ? peersIps[ip] + 1 : 1
- peersIpInfoHash[key] = peersIpInfoHash[key] ? peersIpInfoHash[key] + 1 : 1
+ peersIps[ ip ] = peersIps[ ip ] ? peersIps[ ip ] + 1 : 1
+ peersIpInfoHash[ key ] = peersIpInfoHash[ key ] ? peersIpInfoHash[ key ] + 1 : 1
- if (peersIpInfoHash[key] > TRACKER_RATE_LIMITS.ANNOUNCES_PER_IP_PER_INFOHASH) {
+ if (peersIpInfoHash[ key ] > TRACKER_RATE_LIMITS.ANNOUNCES_PER_IP_PER_INFOHASH) {
return cb(new Error(`Too many requests (${peersIpInfoHash[ key ]} of ip ${ip} for torrent ${infoHash}`))
}
- VideoFileModel.isInfohashExists(infoHash)
- .then(exists => {
- if (exists === false) return cb(new Error(`Unknown infoHash ${infoHash}`))
+ try {
+ const videoFileExists = await VideoFileModel.doesInfohashExist(infoHash)
+ if (videoFileExists === true) return cb()
- return cb()
- })
+ const playlistExists = await VideoStreamingPlaylistModel.doesInfohashExist(infoHash)
+ if (playlistExists === true) return cb()
+
+ return cb(new Error(`Unknown infoHash ${infoHash}`))
+ } catch (err) {
+ logger.error('Error in tracker filter.', { err })
+ return cb(err)
+ }
}
})
'https://w3id.org/security/v1',
{
RsaSignature2017: 'https://w3id.org/security#RsaSignature2017',
- pt: 'https://joinpeertube.org/ns',
+ pt: 'https://joinpeertube.org/ns#',
sc: 'http://schema.org#',
Hashtag: 'as:Hashtag',
uuid: 'sc:identifier',
waitTranscoding: 'sc:Boolean',
expires: 'sc:expires',
support: 'sc:Text',
- CacheFile: 'pt:CacheFile'
+ CacheFile: 'pt:CacheFile',
+ Infohash: 'pt:Infohash'
},
{
likes: {
return truncate(str, options)
}
-function sha256 (str: string, encoding: HexBase64Latin1Encoding = 'hex') {
+function sha256 (str: string | Buffer, encoding: HexBase64Latin1Encoding = 'hex') {
return createHash('sha256').update(str).digest(encoding)
}
+function sha1 (str: string | Buffer, encoding: HexBase64Latin1Encoding = 'hex') {
+ return createHash('sha1').update(str).digest(encoding)
+}
+
function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> {
return function promisified (): Promise<A> {
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
sanitizeHost,
buildPath,
peertubeTruncate,
+
sha256,
+ sha1,
promisify0,
promisify1,
object.type === 'CacheFile' &&
isDateValid(object.expires) &&
isActivityPubUrlValid(object.object) &&
- isRemoteVideoUrlValid(object.url)
+ (isRemoteVideoUrlValid(object.url) || isPlaylistRedundancyUrlValid(object.url))
}
+// ---------------------------------------------------------------------------
+
export {
isCacheFileObjectValid
}
+
+// ---------------------------------------------------------------------------
+
+function isPlaylistRedundancyUrlValid (url: any) {
+ return url.type === 'Link' &&
+ (url.mediaType || url.mimeType) === 'application/x-mpegURL' &&
+ isActivityPubUrlValid(url.href)
+}
import * as validator from 'validator'
import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers'
import { peertubeTruncate } from '../../core-utils'
-import { exists, isBooleanValid, isDateValid, isUUIDValid } from '../misc'
+import { exists, isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc'
import {
isVideoDurationValid,
isVideoNameValid,
} from '../videos'
import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc'
import { VideoState } from '../../../../shared/models/videos'
-import { isVideoAbuseReasonValid } from '../video-abuses'
function sanitizeAndCheckVideoTorrentUpdateActivity (activity: any) {
return isBaseActivityValid(activity, 'Update') &&
ACTIVITY_PUB.URL_MIME_TYPES.MAGNET.indexOf(url.mediaType || url.mimeType) !== -1 &&
validator.isLength(url.href, { min: 5 }) &&
validator.isInt(url.height + '', { min: 0 })
+ ) ||
+ (
+ (url.mediaType || url.mimeType) === 'application/x-mpegURL' &&
+ isActivityPubUrlValid(url.href) &&
+ isArray(url.tag)
)
}
return Array.isArray(value) && value.every(v => validator.isInt('' + v)) && value.length !== 0
}
+function isArrayOf (value: any, validator: (value: any) => boolean) {
+ return isArray(value) && value.every(v => validator(v))
+}
+
function isDateValid (value: string) {
return exists(value) && validator.isISO8601(value)
}
export {
exists,
+ isArrayOf,
isNotEmptyIntArray,
isArray,
isIdValid,
import * as ffmpeg from 'fluent-ffmpeg'
-import { join } from 'path'
+import { dirname, join } from 'path'
import { getTargetBitrate, VideoResolution } from '../../shared/models/videos'
import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
import { processImage } from './image-utils'
return resolutionsEnabled
}
-async function getVideoFileResolution (path: string) {
+async function getVideoFileSize (path: string) {
const videoStream = await getVideoFileStream(path)
return {
- videoFileResolution: Math.min(videoStream.height, videoStream.width),
- isPortraitMode: videoStream.height > videoStream.width
+ width: videoStream.width,
+ height: videoStream.height
+ }
+}
+
+async function getVideoFileResolution (path: string) {
+ const size = await getVideoFileSize(path)
+
+ return {
+ videoFileResolution: Math.min(size.height, size.width),
+ isPortraitMode: size.height > size.width
}
}
type TranscodeOptions = {
inputPath: string
outputPath: string
- resolution?: VideoResolution
+ resolution: VideoResolution
isPortraitMode?: boolean
+
+ generateHlsPlaylist?: boolean
}
function transcode (options: TranscodeOptions) {
command = command.withFPS(fps)
}
+ if (options.generateHlsPlaylist) {
+ const segmentFilename = `${dirname(options.outputPath)}/${options.resolution}_%03d.ts`
+
+ command = command.outputOption('-hls_time 4')
+ .outputOption('-hls_list_size 0')
+ .outputOption('-hls_playlist_type vod')
+ .outputOption('-hls_segment_filename ' + segmentFilename)
+ .outputOption('-f hls')
+ }
+
command
.on('error', (err, stdout, stderr) => {
logger.error('Error in transcoding job.', { stdout, stderr })
// ---------------------------------------------------------------------------
export {
+ getVideoFileSize,
getVideoFileResolution,
getDurationFromVideoFile,
generateImageFromVideoFile,
import { VideoModel } from '../models/video/video'
-type VideoFetchType = 'all' | 'only-video' | 'id' | 'none'
+type VideoFetchType = 'all' | 'only-video' | 'only-video-with-rights' | 'id' | 'none'
function fetchVideo (id: number | string, fetchType: VideoFetchType, userId?: number) {
if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId)
+ if (fetchType === 'only-video-with-rights') return VideoModel.loadWithRights(id)
+
if (fetchType === 'only-video') return VideoModel.load(id)
if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id)
'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', 'database.pool.max',
'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
'storage.avatars', 'storage.videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache',
- 'storage.redundancy', 'storage.tmp',
+ 'storage.redundancy', 'storage.tmp', 'storage.playlists',
'log.level',
'user.video_quota', 'user.video_quota_daily',
'cache.previews.size', 'admin.email', 'contact_form.enabled',
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 325
+const LAST_MIGRATION_VERSION = 330
// ---------------------------------------------------------------------------
AVATARS_DIR: buildPath(config.get<string>('storage.avatars')),
LOG_DIR: buildPath(config.get<string>('storage.logs')),
VIDEOS_DIR: buildPath(config.get<string>('storage.videos')),
+ PLAYLISTS_DIR: buildPath(config.get<string>('storage.playlists')),
REDUNDANCY_DIR: buildPath(config.get<string>('storage.redundancy')),
THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
get '480p' () { return config.get<boolean>('transcoding.resolutions.480p') },
get '720p' () { return config.get<boolean>('transcoding.resolutions.720p') },
get '1080p' () { return config.get<boolean>('transcoding.resolutions.1080p') }
+ },
+ HLS: {
+ get ENABLED () { return config.get<boolean>('transcoding.hls.enabled') }
}
},
IMPORT: {
TORRENTS: '/static/torrents/',
WEBSEED: '/static/webseed/',
REDUNDANCY: '/static/redundancy/',
+ PLAYLISTS: {
+ HLS: '/static/playlists/hls'
+ },
AVATARS: '/static/avatars/',
VIDEO_CAPTIONS: '/static/video-captions/'
}
}
}
+const HLS_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.PLAYLISTS_DIR, 'hls')
+const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
+
const MEMOIZE_TTL = {
OVERVIEWS_SAMPLE: 1000 * 3600 * 4 // 4 hours
}
export {
API_VERSION,
+ HLS_REDUNDANCY_DIRECTORY,
AVATARS_SIZE,
ACCEPT_HEADERS,
BCRYPT_SALT_SIZE,
PRIVATE_RSA_KEY_SIZE,
ROUTE_CACHE_LIFETIME,
SORTABLE_COLUMNS,
+ HLS_PLAYLIST_DIRECTORY,
FEEDS,
JOB_TTL,
NSFW_POLICY_TYPES,
import { ServerBlocklistModel } from '../models/server/server-blocklist'
import { UserNotificationModel } from '../models/account/user-notification'
import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
+import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
AccountBlocklistModel,
ServerBlocklistModel,
UserNotificationModel,
- UserNotificationSettingModel
+ UserNotificationSettingModel,
+ VideoStreamingPlaylistModel
])
// Check extensions exist in the database
import { ApplicationModel } from '../models/application/application'
import { OAuthClientModel } from '../models/oauth/oauth-client'
import { applicationExist, clientsExist, usersExist } from './checker-after-init'
-import { CACHE, CONFIG, LAST_MIGRATION_VERSION } from './constants'
+import { CACHE, CONFIG, HLS_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION } from './constants'
import { sequelizeTypescript } from './database'
import { remove, ensureDir } from 'fs-extra'
tasks.push(ensureDir(dir))
}
+ // Playlist directories
+ tasks.push(ensureDir(HLS_PLAYLIST_DIRECTORY))
+
return Promise.all(tasks)
}
--- /dev/null
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+ transaction: Sequelize.Transaction,
+ queryInterface: Sequelize.QueryInterface,
+ sequelize: Sequelize.Sequelize
+}): Promise<void> {
+
+ {
+ const query = `
+ CREATE TABLE IF NOT EXISTS "videoStreamingPlaylist"
+(
+ "id" SERIAL,
+ "type" INTEGER NOT NULL,
+ "playlistUrl" VARCHAR(2000) NOT NULL,
+ "p2pMediaLoaderInfohashes" VARCHAR(255)[] NOT NULL,
+ "segmentsSha256Url" VARCHAR(255) NOT NULL,
+ "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
+ "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
+ PRIMARY KEY ("id")
+);`
+ await utils.sequelize.query(query)
+ }
+
+ {
+ const data = {
+ type: Sequelize.INTEGER,
+ allowNull: true,
+ defaultValue: null
+ }
+
+ await utils.queryInterface.changeColumn('videoRedundancy', 'videoFileId', data)
+ }
+
+ {
+ const query = 'ALTER TABLE "videoRedundancy" ADD COLUMN "videoStreamingPlaylistId" INTEGER NULL ' +
+ 'REFERENCES "videoStreamingPlaylist" ("id") ON DELETE CASCADE ON UPDATE CASCADE'
+
+ await utils.sequelize.query(query)
+ }
+}
+
+function down (options) {
+ throw new Error('Not implemented.')
+}
+
+export {
+ up,
+ down
+}
-import { CacheFileObject } from '../../../shared/index'
+import { ActivityPlaylistUrlObject, ActivityVideoUrlObject, CacheFileObject } from '../../../shared/index'
import { VideoModel } from '../../models/video/video'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
import { Transaction } from 'sequelize'
+import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: VideoModel, byActor: { id?: number }) {
- const url = cacheFileObject.url
+ if (cacheFileObject.url.mediaType === 'application/x-mpegURL') {
+ const url = cacheFileObject.url
+
+ const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS)
+ if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
+
+ return {
+ expiresOn: new Date(cacheFileObject.expires),
+ url: cacheFileObject.id,
+ fileUrl: url.href,
+ strategy: null,
+ videoStreamingPlaylistId: playlist.id,
+ actorId: byActor.id
+ }
+ }
+
+ const url = cacheFileObject.url
const videoFile = video.VideoFiles.find(f => {
return f.resolution === url.height && f.fps === url.fps
})
return {
expiresOn: new Date(cacheFileObject.expires),
url: cacheFileObject.id,
- fileUrl: cacheFileObject.url.href,
+ fileUrl: url.href,
strategy: null,
videoFileId: videoFile.id,
actorId: byActor.id
import { Transaction } from 'sequelize'
import { ActivityAudience, ActivityCreate } from '../../../../shared/models/activitypub'
-import { VideoPrivacy } from '../../../../shared/models/videos'
+import { Video, VideoPrivacy } from '../../../../shared/models/videos'
import { ActorModel } from '../../../models/activitypub/actor'
import { VideoModel } from '../../../models/video/video'
import { VideoAbuseModel } from '../../../models/video/video-abuse'
return unicastTo(createActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
}
-async function sendCreateCacheFile (byActor: ActorModel, fileRedundancy: VideoRedundancyModel) {
+async function sendCreateCacheFile (byActor: ActorModel, video: VideoModel, fileRedundancy: VideoRedundancyModel) {
logger.info('Creating job to send file cache of %s.', fileRedundancy.url)
- const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(fileRedundancy.VideoFile.Video.id)
- const redundancyObject = fileRedundancy.toActivityPubObject()
-
return sendVideoRelatedCreateActivity({
byActor,
video,
url: fileRedundancy.url,
- object: redundancyObject
+ object: fileRedundancy.toActivityPubObject()
})
}
async function sendUndoCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel, t: Transaction) {
logger.info('Creating job to undo cache file %s.', redundancyModel.url)
- const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.VideoFile.Video.id)
+ const videoId = redundancyModel.getVideo().id
+ const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject())
return sendUndoVideoRelatedActivity({ byActor, video, url: redundancyModel.url, activity: createActivity, transaction: t })
async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel) {
logger.info('Creating job to update cache file %s.', redundancyModel.url)
- const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.VideoFile.Video.id)
+ const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.getVideo().id)
const activityBuilder = (audience: ActivityAudience) => {
const redundancyObject = redundancyModel.toActivityPubObject()
import { VideoAbuseModel } from '../../models/video/video-abuse'
import { VideoCommentModel } from '../../models/video/video-comment'
import { VideoFileModel } from '../../models/video/video-file'
+import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
+import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
function getVideoActivityPubUrl (video: VideoModel) {
return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
return `${CONFIG.WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}`
}
+function getVideoCacheStreamingPlaylistActivityPubUrl (video: VideoModel, playlist: VideoStreamingPlaylistModel) {
+ return `${CONFIG.WEBSERVER.URL}/redundancy/video-playlists/${playlist.getStringType()}/${video.uuid}`
+}
+
function getVideoCommentActivityPubUrl (video: VideoModel, videoComment: VideoCommentModel) {
return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id
}
export {
getVideoActivityPubUrl,
+ getVideoCacheStreamingPlaylistActivityPubUrl,
getVideoChannelActivityPubUrl,
getAccountActivityPubUrl,
getVideoAbuseActivityPubUrl,
import * as sequelize from 'sequelize'
import * as magnetUtil from 'magnet-uri'
import * as request from 'request'
-import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index'
+import {
+ ActivityIconObject,
+ ActivityPlaylistSegmentHashesObject,
+ ActivityPlaylistUrlObject,
+ ActivityUrlObject,
+ ActivityVideoUrlObject,
+ VideoState
+} from '../../../shared/index'
import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
import { VideoPrivacy } from '../../../shared/models/videos'
import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
import { Notifier } from '../notifier'
+import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
+import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
+import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
// If the video is not private and published, we federate it
options.video.VideoFiles = await Promise.all(upsertTasks)
}
+ {
+ const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(options.video, options.videoObject)
+ const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
+
+ // Remove video files that do not exist anymore
+ const destroyTasks = options.video.VideoStreamingPlaylists
+ .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
+ .map(f => f.destroy(sequelizeOptions))
+ await Promise.all(destroyTasks)
+
+ // Update or add other one
+ const upsertTasks = streamingPlaylistAttributes.map(a => {
+ return VideoStreamingPlaylistModel.upsert<VideoStreamingPlaylistModel>(a, { returning: true, transaction: t })
+ .then(([ streamingPlaylist ]) => streamingPlaylist)
+ })
+
+ options.video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
+ }
+
{
// Update Tags
const tags = options.videoObject.tag.map(tag => tag.name)
// ---------------------------------------------------------------------------
-function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
+function isAPVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
const urlMediaType = url.mediaType || url.mimeType
return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
}
+function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject {
+ const urlMediaType = url.mediaType || url.mimeType
+
+ return urlMediaType === 'application/x-mpegURL'
+}
+
+function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
+ const urlMediaType = tag.mediaType || tag.mimeType
+
+ return tag.name === 'sha256' && tag.type === 'Link' && urlMediaType === 'application/json'
+}
+
async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
logger.debug('Adding remote video %s.', videoObject.id)
const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
await Promise.all(videoFilePromises)
+ const videoStreamingPlaylists = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject)
+ const playlistPromises = videoStreamingPlaylists.map(p => VideoStreamingPlaylistModel.create(p, { transaction: t }))
+ await Promise.all(playlistPromises)
+
// Process tags
- const tags = videoObject.tag.map(t => t.name)
+ const tags = videoObject.tag
+ .filter(t => t.type === 'Hashtag')
+ .map(t => t.name)
const tagInstances = await TagModel.findOrCreateTags(tags, t)
await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
}
function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
- const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
+ const fileUrls = videoObject.url.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
if (fileUrls.length === 0) {
throw new Error('Cannot find video files for ' + video.url)
}
- const attributes: VideoFileModel[] = []
+ const attributes: FilteredModelAttributes<VideoFileModel>[] = []
for (const fileUrl of fileUrls) {
// Fetch associated magnet uri
const magnet = videoObject.url.find(u => {
size: fileUrl.size,
videoId: video.id,
fps: fileUrl.fps || -1
- } as VideoFileModel
+ }
+
+ attributes.push(attribute)
+ }
+
+ return attributes
+}
+
+function streamingPlaylistActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
+ const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
+ if (playlistUrls.length === 0) return []
+
+ const attributes: FilteredModelAttributes<VideoStreamingPlaylistModel>[] = []
+ for (const playlistUrlObject of playlistUrls) {
+ const p2pMediaLoaderInfohashes = playlistUrlObject.tag
+ .filter(t => t.type === 'Infohash')
+ .map(t => t.name)
+ if (p2pMediaLoaderInfohashes.length === 0) {
+ logger.warn('No infohashes found in AP playlist object.', { playlistUrl: playlistUrlObject })
+ continue
+ }
+
+ const segmentsSha256UrlObject = playlistUrlObject.tag
+ .find(t => {
+ return isAPPlaylistSegmentHashesUrlObject(t)
+ }) as ActivityPlaylistSegmentHashesObject
+ if (!segmentsSha256UrlObject) {
+ logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
+ continue
+ }
+
+ const attribute = {
+ type: VideoStreamingPlaylistType.HLS,
+ playlistUrl: playlistUrlObject.href,
+ segmentsSha256Url: segmentsSha256UrlObject.href,
+ p2pMediaLoaderInfohashes,
+ videoId: video.id
+ }
+
attributes.push(attribute)
}
--- /dev/null
+import { VideoModel } from '../models/video/video'
+import { basename, dirname, join } from 'path'
+import { HLS_PLAYLIST_DIRECTORY, CONFIG } from '../initializers'
+import { outputJSON, pathExists, readdir, readFile, remove, writeFile, move } from 'fs-extra'
+import { getVideoFileSize } from '../helpers/ffmpeg-utils'
+import { sha256 } from '../helpers/core-utils'
+import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
+import HLSDownloader from 'hlsdownloader'
+import { logger } from '../helpers/logger'
+import { parse } from 'url'
+
+async function updateMasterHLSPlaylist (video: VideoModel) {
+ const directory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
+ const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ]
+ const masterPlaylistPath = join(directory, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
+
+ for (const file of video.VideoFiles) {
+ // If we did not generated a playlist for this resolution, skip
+ const filePlaylistPath = join(directory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution))
+ if (await pathExists(filePlaylistPath) === false) continue
+
+ const videoFilePath = video.getVideoFilePath(file)
+
+ const size = await getVideoFileSize(videoFilePath)
+
+ const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
+ const resolution = `RESOLUTION=${size.width}x${size.height}`
+
+ let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
+ if (file.fps) line += ',FRAME-RATE=' + file.fps
+
+ masterPlaylists.push(line)
+ masterPlaylists.push(VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution))
+ }
+
+ await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
+}
+
+async function updateSha256Segments (video: VideoModel) {
+ const directory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
+ const files = await readdir(directory)
+ const json: { [filename: string]: string} = {}
+
+ for (const file of files) {
+ if (file.endsWith('.ts') === false) continue
+
+ const buffer = await readFile(join(directory, file))
+ const filename = basename(file)
+
+ json[filename] = sha256(buffer)
+ }
+
+ const outputPath = join(directory, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
+ await outputJSON(outputPath, json)
+}
+
+function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number) {
+ let timer
+
+ logger.info('Importing HLS playlist %s', playlistUrl)
+
+ const params = {
+ playlistURL: playlistUrl,
+ destination: CONFIG.STORAGE.TMP_DIR
+ }
+ const downloader = new HLSDownloader(params)
+
+ const hlsDestinationDir = join(CONFIG.STORAGE.TMP_DIR, dirname(parse(playlistUrl).pathname))
+
+ return new Promise<string>(async (res, rej) => {
+ downloader.startDownload(err => {
+ clearTimeout(timer)
+
+ if (err) {
+ deleteTmpDirectory(hlsDestinationDir)
+
+ return rej(err)
+ }
+
+ move(hlsDestinationDir, destinationDir, { overwrite: true })
+ .then(() => res())
+ .catch(err => {
+ deleteTmpDirectory(hlsDestinationDir)
+
+ return rej(err)
+ })
+ })
+
+ timer = setTimeout(() => {
+ deleteTmpDirectory(hlsDestinationDir)
+
+ return rej(new Error('HLS download timeout.'))
+ }, timeout)
+
+ function deleteTmpDirectory (directory: string) {
+ remove(directory)
+ .catch(err => logger.error('Cannot delete path on HLS download error.', { err }))
+ }
+ })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ updateMasterHLSPlaylist,
+ updateSha256Segments,
+ downloadPlaylistSegments
+}
+
+// ---------------------------------------------------------------------------
import { JobQueue } from '../job-queue'
import { federateVideoIfNeeded } from '../../activitypub'
import { retryTransactionWrapper } from '../../../helpers/database-utils'
-import { sequelizeTypescript } from '../../../initializers'
+import { sequelizeTypescript, CONFIG } from '../../../initializers'
import * as Bluebird from 'bluebird'
import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
-import { importVideoFile, optimizeVideofile, transcodeOriginalVideofile } from '../../video-transcoding'
+import { generateHlsPlaylist, importVideoFile, optimizeVideofile, transcodeOriginalVideofile } from '../../video-transcoding'
import { Notifier } from '../../notifier'
export type VideoFilePayload = {
videoUUID: string
- isNewVideo?: boolean
resolution?: VideoResolution
+ isNewVideo?: boolean
isPortraitMode?: boolean
+ generateHlsPlaylist?: boolean
}
export type VideoFileImportPayload = {
return undefined
}
- // Transcoding in other resolution
- if (payload.resolution) {
+ if (payload.generateHlsPlaylist) {
+ await generateHlsPlaylist(video, payload.resolution, payload.isPortraitMode || false)
+
+ await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video)
+ } else if (payload.resolution) { // Transcoding in other resolution
await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false)
- await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video)
+ await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video, payload)
} else {
await optimizeVideofile(video)
- await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo)
+ await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload)
}
return video
}
-async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) {
+async function onHlsPlaylistGenerationSuccess (video: VideoModel) {
+ if (video === undefined) return undefined
+
+ await sequelizeTypescript.transaction(async t => {
+ // Maybe the video changed in database, refresh it
+ let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
+ // Video does not exist anymore
+ if (!videoDatabase) return undefined
+
+ // If the video was not published, we consider it is a new one for other instances
+ await federateVideoIfNeeded(videoDatabase, false, t)
+ })
+}
+
+async function onVideoFileTranscoderOrImportSuccess (video: VideoModel, payload?: VideoFilePayload) {
if (video === undefined) return undefined
const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => {
Notifier.Instance.notifyOnNewVideo(videoDatabase)
Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase)
}
+
+ await createHlsJobIfEnabled(payload)
}
-async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: boolean) {
+async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: VideoFilePayload) {
if (videoArg === undefined) return undefined
// Outside the transaction (IO on disk)
logger.info('No transcoding jobs created for video %s (no resolutions).', videoDatabase.uuid, { privacy: videoDatabase.privacy })
}
- await federateVideoIfNeeded(videoDatabase, isNewVideo, t)
+ await federateVideoIfNeeded(videoDatabase, payload.isNewVideo, t)
return { videoDatabase, videoPublished }
})
if (isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase)
if (videoPublished) Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase)
}
+
+ await createHlsJobIfEnabled(Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution }))
}
// ---------------------------------------------------------------------------
processVideoFile,
processVideoFileImport
}
+
+// ---------------------------------------------------------------------------
+
+function createHlsJobIfEnabled (payload?: VideoFilePayload) {
+ // Generate HLS playlist?
+ if (payload && CONFIG.TRANSCODING.HLS.ENABLED) {
+ const hlsTranscodingPayload = {
+ videoUUID: payload.videoUUID,
+ resolution: payload.resolution,
+ isPortraitMode: payload.isPortraitMode,
+
+ generateHlsPlaylist: true
+ }
+
+ return JobQueue.Instance.createJob({ type: 'video-file', payload: hlsTranscodingPayload })
+ }
+}
import { AbstractScheduler } from './abstract-scheduler'
-import { CONFIG, REDUNDANCY, VIDEO_IMPORT_TIMEOUT } from '../../initializers'
+import { CONFIG, HLS_REDUNDANCY_DIRECTORY, REDUNDANCY, VIDEO_IMPORT_TIMEOUT } from '../../initializers'
import { logger } from '../../helpers/logger'
import { VideosRedundancy } from '../../../shared/models/redundancy'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
import { move } from 'fs-extra'
import { getServerActor } from '../../helpers/utils'
import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send'
-import { getVideoCacheFileActivityPubUrl } from '../activitypub/url'
+import { getVideoCacheFileActivityPubUrl, getVideoCacheStreamingPlaylistActivityPubUrl } from '../activitypub/url'
import { removeVideoRedundancy } from '../redundancy'
import { getOrCreateVideoAndAccountAndChannel } from '../activitypub'
+import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
+import { VideoModel } from '../../models/video/video'
+import { downloadPlaylistSegments } from '../hls'
+
+type CandidateToDuplicate = {
+ redundancy: VideosRedundancy,
+ video: VideoModel,
+ files: VideoFileModel[],
+ streamingPlaylists: VideoStreamingPlaylistModel[]
+}
export class VideosRedundancyScheduler extends AbstractScheduler {
}
protected async internalExecute () {
- for (const obj of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) {
- logger.info('Running redundancy scheduler for strategy %s.', obj.strategy)
+ for (const redundancyConfig of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) {
+ logger.info('Running redundancy scheduler for strategy %s.', redundancyConfig.strategy)
try {
- const videoToDuplicate = await this.findVideoToDuplicate(obj)
+ const videoToDuplicate = await this.findVideoToDuplicate(redundancyConfig)
if (!videoToDuplicate) continue
- const videoFiles = videoToDuplicate.VideoFiles
- videoFiles.forEach(f => f.Video = videoToDuplicate)
+ const candidateToDuplicate = {
+ video: videoToDuplicate,
+ redundancy: redundancyConfig,
+ files: videoToDuplicate.VideoFiles,
+ streamingPlaylists: videoToDuplicate.VideoStreamingPlaylists
+ }
- await this.purgeCacheIfNeeded(obj, videoFiles)
+ await this.purgeCacheIfNeeded(candidateToDuplicate)
- if (await this.isTooHeavy(obj, videoFiles)) {
+ if (await this.isTooHeavy(candidateToDuplicate)) {
logger.info('Video %s is too big for our cache, skipping.', videoToDuplicate.url)
continue
}
- logger.info('Will duplicate video %s in redundancy scheduler "%s".', videoToDuplicate.url, obj.strategy)
+ logger.info('Will duplicate video %s in redundancy scheduler "%s".', videoToDuplicate.url, redundancyConfig.strategy)
- await this.createVideoRedundancy(obj, videoFiles)
+ await this.createVideoRedundancies(candidateToDuplicate)
} catch (err) {
- logger.error('Cannot run videos redundancy %s.', obj.strategy, { err })
+ logger.error('Cannot run videos redundancy %s.', redundancyConfig.strategy, { err })
}
}
for (const redundancyModel of expired) {
try {
- await this.extendsOrDeleteRedundancy(redundancyModel)
+ const redundancyConfig = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy)
+ const candidate = {
+ redundancy: redundancyConfig,
+ video: null,
+ files: [],
+ streamingPlaylists: []
+ }
+
+ // If the administrator disabled the redundancy or decreased the cache size, remove this redundancy instead of extending it
+ if (!redundancyConfig || await this.isTooHeavy(candidate)) {
+ logger.info('Destroying redundancy %s because the cache size %s is too heavy.', redundancyModel.url, redundancyModel.strategy)
+ await removeVideoRedundancy(redundancyModel)
+ } else {
+ await this.extendsRedundancy(redundancyModel)
+ }
} catch (err) {
- logger.error('Cannot extend expiration of %s video from our redundancy system.', this.buildEntryLogId(redundancyModel))
+ logger.error(
+ 'Cannot extend or remove expiration of %s video from our redundancy system.', this.buildEntryLogId(redundancyModel),
+ { err }
+ )
}
}
}
- private async extendsOrDeleteRedundancy (redundancyModel: VideoRedundancyModel) {
- // Refresh the video, maybe it was deleted
- const video = await this.loadAndRefreshVideo(redundancyModel.VideoFile.Video.url)
-
- if (!video) {
- logger.info('Destroying existing redundancy %s, because the associated video does not exist anymore.', redundancyModel.url)
-
- await redundancyModel.destroy()
- return
- }
-
+ private async extendsRedundancy (redundancyModel: VideoRedundancyModel) {
const redundancy = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy)
+ // Redundancy strategy disabled, remove our redundancy instead of extending expiration
+ if (!redundancy) await removeVideoRedundancy(redundancyModel)
+
await this.extendsExpirationOf(redundancyModel, redundancy.minLifetime)
}
}
}
- private async createVideoRedundancy (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) {
- const serverActor = await getServerActor()
+ private async createVideoRedundancies (data: CandidateToDuplicate) {
+ const video = await this.loadAndRefreshVideo(data.video.url)
+
+ if (!video) {
+ logger.info('Video %s we want to duplicate does not existing anymore, skipping.', data.video.url)
- for (const file of filesToDuplicate) {
- const video = await this.loadAndRefreshVideo(file.Video.url)
+ return
+ }
+ for (const file of data.files) {
const existingRedundancy = await VideoRedundancyModel.loadLocalByFileId(file.id)
if (existingRedundancy) {
- await this.extendsOrDeleteRedundancy(existingRedundancy)
+ await this.extendsRedundancy(existingRedundancy)
continue
}
- if (!video) {
- logger.info('Video %s we want to duplicate does not existing anymore, skipping.', file.Video.url)
+ await this.createVideoFileRedundancy(data.redundancy, video, file)
+ }
+
+ for (const streamingPlaylist of data.streamingPlaylists) {
+ const existingRedundancy = await VideoRedundancyModel.loadLocalByStreamingPlaylistId(streamingPlaylist.id)
+ if (existingRedundancy) {
+ await this.extendsRedundancy(existingRedundancy)
continue
}
- logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, redundancy.strategy)
+ await this.createStreamingPlaylistRedundancy(data.redundancy, video, streamingPlaylist)
+ }
+ }
- const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
- const magnetUri = video.generateMagnetUri(file, baseUrlHttp, baseUrlWs)
+ private async createVideoFileRedundancy (redundancy: VideosRedundancy, video: VideoModel, file: VideoFileModel) {
+ file.Video = video
- const tmpPath = await downloadWebTorrentVideo({ magnetUri }, VIDEO_IMPORT_TIMEOUT)
+ const serverActor = await getServerActor()
- const destPath = join(CONFIG.STORAGE.REDUNDANCY_DIR, video.getVideoFilename(file))
- await move(tmpPath, destPath)
+ logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, redundancy.strategy)
- const createdModel = await VideoRedundancyModel.create({
- expiresOn: this.buildNewExpiration(redundancy.minLifetime),
- url: getVideoCacheFileActivityPubUrl(file),
- fileUrl: video.getVideoRedundancyUrl(file, CONFIG.WEBSERVER.URL),
- strategy: redundancy.strategy,
- videoFileId: file.id,
- actorId: serverActor.id
- })
- createdModel.VideoFile = file
+ const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
+ const magnetUri = video.generateMagnetUri(file, baseUrlHttp, baseUrlWs)
- await sendCreateCacheFile(serverActor, createdModel)
+ const tmpPath = await downloadWebTorrentVideo({ magnetUri }, VIDEO_IMPORT_TIMEOUT)
- logger.info('Duplicated %s - %d -> %s.', video.url, file.resolution, createdModel.url)
- }
+ const destPath = join(CONFIG.STORAGE.REDUNDANCY_DIR, video.getVideoFilename(file))
+ await move(tmpPath, destPath)
+
+ const createdModel = await VideoRedundancyModel.create({
+ expiresOn: this.buildNewExpiration(redundancy.minLifetime),
+ url: getVideoCacheFileActivityPubUrl(file),
+ fileUrl: video.getVideoRedundancyUrl(file, CONFIG.WEBSERVER.URL),
+ strategy: redundancy.strategy,
+ videoFileId: file.id,
+ actorId: serverActor.id
+ })
+
+ createdModel.VideoFile = file
+
+ await sendCreateCacheFile(serverActor, video, createdModel)
+
+ logger.info('Duplicated %s - %d -> %s.', video.url, file.resolution, createdModel.url)
+ }
+
+ private async createStreamingPlaylistRedundancy (redundancy: VideosRedundancy, video: VideoModel, playlist: VideoStreamingPlaylistModel) {
+ playlist.Video = video
+
+ const serverActor = await getServerActor()
+
+ logger.info('Duplicating %s streaming playlist in videos redundancy with "%s" strategy.', video.url, redundancy.strategy)
+
+ const destDirectory = join(HLS_REDUNDANCY_DIRECTORY, video.uuid)
+ await downloadPlaylistSegments(playlist.playlistUrl, destDirectory, VIDEO_IMPORT_TIMEOUT)
+
+ const createdModel = await VideoRedundancyModel.create({
+ expiresOn: this.buildNewExpiration(redundancy.minLifetime),
+ url: getVideoCacheStreamingPlaylistActivityPubUrl(video, playlist),
+ fileUrl: playlist.getVideoRedundancyUrl(CONFIG.WEBSERVER.URL),
+ strategy: redundancy.strategy,
+ videoStreamingPlaylistId: playlist.id,
+ actorId: serverActor.id
+ })
+
+ createdModel.VideoStreamingPlaylist = playlist
+
+ await sendCreateCacheFile(serverActor, video, createdModel)
+
+ logger.info('Duplicated playlist %s -> %s.', playlist.playlistUrl, createdModel.url)
}
private async extendsExpirationOf (redundancy: VideoRedundancyModel, expiresAfterMs: number) {
await sendUpdateCacheFile(serverActor, redundancy)
}
- private async purgeCacheIfNeeded (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) {
- while (this.isTooHeavy(redundancy, filesToDuplicate)) {
+ private async purgeCacheIfNeeded (candidateToDuplicate: CandidateToDuplicate) {
+ while (this.isTooHeavy(candidateToDuplicate)) {
+ const redundancy = candidateToDuplicate.redundancy
const toDelete = await VideoRedundancyModel.loadOldestLocalThatAlreadyExpired(redundancy.strategy, redundancy.minLifetime)
if (!toDelete) return
}
}
- private async isTooHeavy (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) {
- const maxSize = redundancy.size
+ private async isTooHeavy (candidateToDuplicate: CandidateToDuplicate) {
+ const maxSize = candidateToDuplicate.redundancy.size
- const totalDuplicated = await VideoRedundancyModel.getTotalDuplicated(redundancy.strategy)
- const totalWillDuplicate = totalDuplicated + this.getTotalFileSizes(filesToDuplicate)
+ const totalDuplicated = await VideoRedundancyModel.getTotalDuplicated(candidateToDuplicate.redundancy.strategy)
+ const totalWillDuplicate = totalDuplicated + this.getTotalFileSizes(candidateToDuplicate.files, candidateToDuplicate.streamingPlaylists)
return totalWillDuplicate > maxSize
}
}
private buildEntryLogId (object: VideoRedundancyModel) {
- return `${object.VideoFile.Video.url}-${object.VideoFile.resolution}`
+ if (object.VideoFile) return `${object.VideoFile.Video.url}-${object.VideoFile.resolution}`
+
+ return `${object.VideoStreamingPlaylist.playlistUrl}`
}
- private getTotalFileSizes (files: VideoFileModel[]) {
+ private getTotalFileSizes (files: VideoFileModel[], playlists: VideoStreamingPlaylistModel[]) {
const fileReducer = (previous: number, current: VideoFileModel) => previous + current.size
- return files.reduce(fileReducer, 0)
+ return files.reduce(fileReducer, 0) * playlists.length
}
private async loadAndRefreshVideo (videoUrl: string) {
-import { CONFIG } from '../initializers'
+import { CONFIG, HLS_PLAYLIST_DIRECTORY } from '../initializers'
import { extname, join } from 'path'
import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils'
-import { copy, remove, move, stat } from 'fs-extra'
+import { copy, ensureDir, move, remove, stat } from 'fs-extra'
import { logger } from '../helpers/logger'
import { VideoResolution } from '../../shared/models/videos'
import { VideoFileModel } from '../models/video/video-file'
import { VideoModel } from '../models/video/video'
+import { updateMasterHLSPlaylist, updateSha256Segments } from './hls'
+import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
+import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type'
async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFileModel) {
const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
const transcodeOptions = {
inputPath: videoInputPath,
- outputPath: videoTranscodedPath
+ outputPath: videoTranscodedPath,
+ resolution: inputVideoFile.resolution
}
// Could be very long!
}
}
-async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) {
+async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoResolution, isPortrait: boolean) {
const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
const extname = '.mp4'
size: 0,
videoId: video.id
})
- const videoOutputPath = join(videosDirectory, video.getVideoFilename(newVideoFile))
+ const videoOutputPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(newVideoFile))
const transcodeOptions = {
inputPath: videoInputPath,
outputPath: videoOutputPath,
resolution,
- isPortraitMode
+ isPortraitMode: isPortrait
}
await transcode(transcodeOptions)
video.VideoFiles.push(newVideoFile)
}
+async function generateHlsPlaylist (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) {
+ const baseHlsDirectory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
+ await ensureDir(join(HLS_PLAYLIST_DIRECTORY, video.uuid))
+
+ const videoInputPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(video.getOriginalFile()))
+ const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
+
+ const transcodeOptions = {
+ inputPath: videoInputPath,
+ outputPath,
+ resolution,
+ isPortraitMode,
+ generateHlsPlaylist: true
+ }
+
+ await transcode(transcodeOptions)
+
+ await updateMasterHLSPlaylist(video)
+ await updateSha256Segments(video)
+
+ const playlistUrl = CONFIG.WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
+
+ await VideoStreamingPlaylistModel.upsert({
+ videoId: video.id,
+ playlistUrl,
+ segmentsSha256Url: CONFIG.WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid),
+ p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles),
+
+ type: VideoStreamingPlaylistType.HLS
+ })
+}
+
async function importVideoFile (video: VideoModel, inputFilePath: string) {
const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
const { size } = await stat(inputFilePath)
}
export {
+ generateHlsPlaylist,
optimizeVideofile,
transcodeOriginalVideofile,
importVideoFile
import { SERVER_ACTOR_NAME } from '../../initializers'
import { ServerModel } from '../../models/server/server'
-const videoRedundancyGetValidator = [
+const videoFileRedundancyGetValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
param('resolution')
.customSanitizer(toIntOrNull)
.custom(exists).withMessage('Should have a valid fps'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
- logger.debug('Checking videoRedundancyGetValidator parameters', { parameters: req.params })
+ logger.debug('Checking videoFileRedundancyGetValidator parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
if (!await isVideoExist(req.params.videoId, res)) return
res.locals.videoFile = videoFile
const videoRedundancy = await VideoRedundancyModel.loadLocalByFileId(videoFile.id)
- if (!videoRedundancy)return res.status(404).json({ error: 'Video redundancy not found.' })
+ if (!videoRedundancy) return res.status(404).json({ error: 'Video redundancy not found.' })
+ res.locals.videoRedundancy = videoRedundancy
+
+ return next()
+ }
+]
+
+const videoPlaylistRedundancyGetValidator = [
+ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
+ param('streamingPlaylistType').custom(exists).withMessage('Should have a valid streaming playlist type'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking videoPlaylistRedundancyGetValidator parameters', { parameters: req.params })
+
+ if (areValidationErrors(req, res)) return
+ if (!await isVideoExist(req.params.videoId, res)) return
+
+ const video: VideoModel = res.locals.video
+ const videoStreamingPlaylist = video.VideoStreamingPlaylists.find(p => p === req.params.streamingPlaylistType)
+
+ if (!videoStreamingPlaylist) return res.status(404).json({ error: 'Video playlist not found.' })
+ res.locals.videoStreamingPlaylist = videoStreamingPlaylist
+
+ const videoRedundancy = await VideoRedundancyModel.loadLocalByStreamingPlaylistId(videoStreamingPlaylist.id)
+ if (!videoRedundancy) return res.status(404).json({ error: 'Video redundancy not found.' })
res.locals.videoRedundancy = videoRedundancy
return next()
// ---------------------------------------------------------------------------
export {
- videoRedundancyGetValidator,
+ videoFileRedundancyGetValidator,
+ videoPlaylistRedundancyGetValidator,
updateServerRedundancyValidator
}
import { isTestInstance } from '../../helpers/core-utils'
import * as Bluebird from 'bluebird'
import * as Sequelize from 'sequelize'
+import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
export enum ScopeNames {
WITH_VIDEO = 'WITH_VIDEO'
include: [
{
model: () => VideoFileModel,
- required: true,
+ required: false,
+ include: [
+ {
+ model: () => VideoModel,
+ required: true
+ }
+ ]
+ },
+ {
+ model: () => VideoStreamingPlaylistModel,
+ required: false,
include: [
{
model: () => VideoModel,
@BelongsTo(() => VideoFileModel, {
foreignKey: {
- allowNull: false
+ allowNull: true
},
onDelete: 'cascade'
})
VideoFile: VideoFileModel
+ @ForeignKey(() => VideoStreamingPlaylistModel)
+ @Column
+ videoStreamingPlaylistId: number
+
+ @BelongsTo(() => VideoStreamingPlaylistModel, {
+ foreignKey: {
+ allowNull: true
+ },
+ onDelete: 'cascade'
+ })
+ VideoStreamingPlaylist: VideoStreamingPlaylistModel
+
@ForeignKey(() => ActorModel)
@Column
actorId: number
static async removeFile (instance: VideoRedundancyModel) {
if (!instance.isOwned()) return
- const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
+ if (instance.videoFileId) {
+ const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
- const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
- logger.info('Removing duplicated video file %s.', logIdentifier)
+ const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
+ logger.info('Removing duplicated video file %s.', logIdentifier)
- videoFile.Video.removeFile(videoFile, true)
- .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
+ videoFile.Video.removeFile(videoFile, true)
+ .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
+ }
+
+ if (instance.videoStreamingPlaylistId) {
+ const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId)
+
+ const videoUUID = videoStreamingPlaylist.Video.uuid
+ logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
+
+ videoStreamingPlaylist.Video.removeStreamingPlaylist(true)
+ .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
+ }
return undefined
}
return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
}
+ static async loadLocalByStreamingPlaylistId (videoStreamingPlaylistId: number) {
+ const actor = await getServerActor()
+
+ const query = {
+ where: {
+ actorId: actor.id,
+ videoStreamingPlaylistId
+ }
+ }
+
+ return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
+ }
+
static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
const query = {
where: {
const ids = rows.map(r => r.id)
const id = sample(ids)
- return VideoModel.loadWithFile(id, undefined, !isTestInstance())
+ return VideoModel.loadWithFiles(id, undefined, !isTestInstance())
}
static async findMostViewToDuplicate (randomizedFactor: number) {
static async listLocalOfServer (serverId: number) {
const actor = await getServerActor()
-
- const query = {
- where: {
- actorId: actor.id
- },
+ const buildVideoInclude = () => ({
+ model: VideoModel,
+ required: true,
include: [
{
- model: VideoFileModel,
+ attributes: [],
+ model: VideoChannelModel.unscoped(),
required: true,
include: [
{
- model: VideoModel,
+ attributes: [],
+ model: ActorModel.unscoped(),
required: true,
- include: [
- {
- attributes: [],
- model: VideoChannelModel.unscoped(),
- required: true,
- include: [
- {
- attributes: [],
- model: ActorModel.unscoped(),
- required: true,
- where: {
- serverId
- }
- }
- ]
- }
- ]
+ where: {
+ serverId
+ }
}
]
}
]
+ })
+
+ const query = {
+ where: {
+ actorId: actor.id
+ },
+ include: [
+ {
+ model: VideoFileModel,
+ required: false,
+ include: [ buildVideoInclude() ]
+ },
+ {
+ model: VideoStreamingPlaylistModel,
+ required: false,
+ include: [ buildVideoInclude() ]
+ }
+ ]
}
return VideoRedundancyModel.findAll(query)
}))
}
+ getVideo () {
+ if (this.VideoFile) return this.VideoFile.Video
+
+ return this.VideoStreamingPlaylist.Video
+ }
+
isOwned () {
return !!this.strategy
}
toActivityPubObject (): CacheFileObject {
+ if (this.VideoStreamingPlaylist) {
+ return {
+ id: this.url,
+ type: 'CacheFile' as 'CacheFile',
+ object: this.VideoStreamingPlaylist.Video.url,
+ expires: this.expiresOn.toISOString(),
+ url: {
+ type: 'Link',
+ mimeType: 'application/x-mpegURL',
+ mediaType: 'application/x-mpegURL',
+ href: this.fileUrl
+ }
+ }
+ }
+
return {
id: this.url,
type: 'CacheFile' as 'CacheFile',
const notIn = Sequelize.literal(
'(' +
- `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id}` +
+ `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "videoFileId" IS NOT NULL` +
')'
)
extname: string
@AllowNull(false)
- @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
+ @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
@Column
infoHash: string
@HasMany(() => VideoRedundancyModel, {
foreignKey: {
- allowNull: false
+ allowNull: true
},
onDelete: 'CASCADE',
hooks: true
})
RedundancyVideos: VideoRedundancyModel[]
- static isInfohashExists (infoHash: string) {
+ static doesInfohashExist (infoHash: string) {
const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
const options = {
type: Sequelize.QueryTypes.SELECT,
import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
import { VideoModel } from './video'
import { VideoFileModel } from './video-file'
-import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects'
+import {
+ ActivityPlaylistInfohashesObject,
+ ActivityPlaylistSegmentHashesObject,
+ ActivityUrlObject,
+ VideoTorrentObject
+} from '../../../shared/models/activitypub/objects'
import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers'
import { VideoCaptionModel } from './video-caption'
import {
getVideoSharesActivityPubUrl
} from '../../lib/activitypub'
import { isArray } from '../../helpers/custom-validators/misc'
+import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
+import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
export type VideoFormattingJSONOptions = {
completeDescription?: boolean
}
})
+ const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
+
const tags = video.Tags ? video.Tags.map(t => t.name) : []
+
+ const streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
+
const detailsJson = {
support: video.support,
descriptionPath: video.getDescriptionAPIPath(),
id: video.state,
label: VideoModel.getStateLabel(video.state)
},
- files: []
+
+ trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs),
+
+ files: [],
+ streamingPlaylists
}
// Format and sort video files
return Object.assign(formattedJson, detailsJson)
}
+function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: VideoStreamingPlaylistModel[]): VideoStreamingPlaylist[] {
+ if (isArray(playlists) === false) return []
+
+ return playlists
+ .map(playlist => {
+ const redundancies = isArray(playlist.RedundancyVideos)
+ ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
+ : []
+
+ return {
+ id: playlist.id,
+ type: playlist.type,
+ playlistUrl: playlist.playlistUrl,
+ segmentsSha256Url: playlist.segmentsSha256Url,
+ redundancies
+ } as VideoStreamingPlaylist
+ })
+}
+
function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
})
}
+ for (const playlist of (video.VideoStreamingPlaylists || [])) {
+ let tag: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[]
+
+ tag = playlist.p2pMediaLoaderInfohashes
+ .map(i => ({ type: 'Infohash' as 'Infohash', name: i }))
+ tag.push({
+ type: 'Link',
+ name: 'sha256',
+ mimeType: 'application/json' as 'application/json',
+ mediaType: 'application/json' as 'application/json',
+ href: playlist.segmentsSha256Url
+ })
+
+ url.push({
+ type: 'Link',
+ mimeType: 'application/x-mpegURL' as 'application/x-mpegURL',
+ mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
+ href: playlist.playlistUrl,
+ tag
+ })
+ }
+
// Add video url too
url.push({
type: 'Link',
--- /dev/null
+import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
+import { throwIfNotValid } from '../utils'
+import { VideoModel } from './video'
+import * as Sequelize from 'sequelize'
+import { VideoRedundancyModel } from '../redundancy/video-redundancy'
+import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
+import { CONSTRAINTS_FIELDS, STATIC_PATHS } from '../../initializers'
+import { VideoFileModel } from './video-file'
+import { join } from 'path'
+import { sha1 } from '../../helpers/core-utils'
+import { isArrayOf } from '../../helpers/custom-validators/misc'
+
+@Table({
+ tableName: 'videoStreamingPlaylist',
+ indexes: [
+ {
+ fields: [ 'videoId' ]
+ },
+ {
+ fields: [ 'videoId', 'type' ],
+ unique: true
+ },
+ {
+ fields: [ 'p2pMediaLoaderInfohashes' ],
+ using: 'gin'
+ }
+ ]
+})
+export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistModel> {
+ @CreatedAt
+ createdAt: Date
+
+ @UpdatedAt
+ updatedAt: Date
+
+ @AllowNull(false)
+ @Column
+ type: VideoStreamingPlaylistType
+
+ @AllowNull(false)
+ @Is('PlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'playlist url'))
+ @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
+ playlistUrl: string
+
+ @AllowNull(false)
+ @Is('VideoStreamingPlaylistInfoHashes', value => throwIfNotValid(value, v => isArrayOf(v, isVideoFileInfoHashValid), 'info hashes'))
+ @Column(DataType.ARRAY(DataType.STRING))
+ p2pMediaLoaderInfohashes: string[]
+
+ @AllowNull(false)
+ @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url'))
+ @Column
+ segmentsSha256Url: string
+
+ @ForeignKey(() => VideoModel)
+ @Column
+ videoId: number
+
+ @BelongsTo(() => VideoModel, {
+ foreignKey: {
+ allowNull: false
+ },
+ onDelete: 'CASCADE'
+ })
+ Video: VideoModel
+
+ @HasMany(() => VideoRedundancyModel, {
+ foreignKey: {
+ allowNull: false
+ },
+ onDelete: 'CASCADE',
+ hooks: true
+ })
+ RedundancyVideos: VideoRedundancyModel[]
+
+ static doesInfohashExist (infoHash: string) {
+ const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
+ const options = {
+ type: Sequelize.QueryTypes.SELECT,
+ bind: { infoHash },
+ raw: true
+ }
+
+ return VideoModel.sequelize.query(query, options)
+ .then(results => {
+ return results.length === 1
+ })
+ }
+
+ static buildP2PMediaLoaderInfoHashes (playlistUrl: string, videoFiles: VideoFileModel[]) {
+ const hashes: string[] = []
+
+ // https://github.com/Novage/p2p-media-loader/blob/master/p2p-media-loader-core/lib/p2p-media-manager.ts#L97
+ for (let i = 0; i < videoFiles.length; i++) {
+ hashes.push(sha1(`1${playlistUrl}+V${i}`))
+ }
+
+ return hashes
+ }
+
+ static loadWithVideo (id: number) {
+ const options = {
+ include: [
+ {
+ model: VideoModel.unscoped(),
+ required: true
+ }
+ ]
+ }
+
+ return VideoStreamingPlaylistModel.findById(id, options)
+ }
+
+ static getHlsPlaylistFilename (resolution: number) {
+ return resolution + '.m3u8'
+ }
+
+ static getMasterHlsPlaylistFilename () {
+ return 'master.m3u8'
+ }
+
+ static getHlsSha256SegmentsFilename () {
+ return 'segments-sha256.json'
+ }
+
+ static getHlsMasterPlaylistStaticPath (videoUUID: string) {
+ return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
+ }
+
+ static getHlsPlaylistStaticPath (videoUUID: string, resolution: number) {
+ return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
+ }
+
+ static getHlsSha256SegmentsStaticPath (videoUUID: string) {
+ return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
+ }
+
+ getStringType () {
+ if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'
+
+ return 'unknown'
+ }
+
+ getVideoRedundancyUrl (baseUrlHttp: string) {
+ return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getStringType() + '/' + this.Video.uuid
+ }
+
+ hasSameUniqueKeysThan (other: VideoStreamingPlaylistModel) {
+ return this.type === other.type &&
+ this.videoId === other.videoId
+ }
+}
ACTIVITY_PUB,
API_VERSION,
CONFIG,
- CONSTRAINTS_FIELDS,
+ CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY,
PREVIEWS_SIZE,
REMOTE_SCHEME,
STATIC_DOWNLOAD_PATHS,
import { UserVideoHistoryModel } from '../account/user-video-history'
import { UserModel } from '../account/user'
import { VideoImportModel } from './video-import'
+import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
const indexes: Sequelize.DefineIndexesOptions[] = [
WITH_FILES = 'WITH_FILES',
WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
WITH_BLACKLISTED = 'WITH_BLACKLISTED',
- WITH_USER_HISTORY = 'WITH_USER_HISTORY'
+ WITH_USER_HISTORY = 'WITH_USER_HISTORY',
+ WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
+ WITH_USER_ID = 'WITH_USER_ID'
}
type ForAPIOptions = {
return query
},
+ [ ScopeNames.WITH_USER_ID ]: {
+ include: [
+ {
+ attributes: [ 'accountId' ],
+ model: () => VideoChannelModel.unscoped(),
+ required: true,
+ include: [
+ {
+ attributes: [ 'userId' ],
+ model: () => AccountModel.unscoped(),
+ required: true
+ }
+ ]
+ }
+ ]
+ },
[ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
include: [
{
}
]
},
- [ ScopeNames.WITH_FILES ]: {
- include: [
- {
- model: () => VideoFileModel.unscoped(),
- // FIXME: typings
- [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join
- required: false,
- include: [
- {
- attributes: [ 'fileUrl' ],
- model: () => VideoRedundancyModel.unscoped(),
- required: false
- }
- ]
- }
- ]
+ [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => {
+ let subInclude: any[] = []
+
+ if (withRedundancies === true) {
+ subInclude = [
+ {
+ attributes: [ 'fileUrl' ],
+ model: VideoRedundancyModel.unscoped(),
+ required: false
+ }
+ ]
+ }
+
+ return {
+ include: [
+ {
+ model: VideoFileModel.unscoped(),
+ // FIXME: typings
+ [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join
+ required: false,
+ include: subInclude
+ }
+ ]
+ }
+ },
+ [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => {
+ let subInclude: any[] = []
+
+ if (withRedundancies === true) {
+ subInclude = [
+ {
+ attributes: [ 'fileUrl' ],
+ model: VideoRedundancyModel.unscoped(),
+ required: false
+ }
+ ]
+ }
+
+ return {
+ include: [
+ {
+ model: VideoStreamingPlaylistModel.unscoped(),
+ // FIXME: typings
+ [ 'separate' as any ]: true, // We may have multiple streaming playlists, having multiple redundancies so let's separate this join
+ required: false,
+ include: subInclude
+ }
+ ]
+ }
},
[ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
include: [
})
VideoFiles: VideoFileModel[]
+ @HasMany(() => VideoStreamingPlaylistModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ hooks: true,
+ onDelete: 'cascade'
+ })
+ VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
+
@HasMany(() => VideoShareModel, {
foreignKey: {
name: 'videoId',
tasks.push(instance.removeFile(file))
tasks.push(instance.removeTorrent(file))
})
+
+ // Remove playlists file
+ tasks.push(instance.removeStreamingPlaylist())
}
// Do not wait video deletion because we could be in a transaction
return undefined
}
- static list () {
- return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
- }
-
static listLocal () {
const query = {
where: {
}
}
- return VideoModel.scope(ScopeNames.WITH_FILES).findAll(query)
+ return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]).findAll(query)
}
static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
return VideoModel.findOne(options)
}
+ static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
+ const where = VideoModel.buildWhereIdOrUUID(id)
+ const options = {
+ where,
+ transaction: t
+ }
+
+ return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_USER_ID ]).findOne(options)
+ }
+
static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
const where = VideoModel.buildWhereIdOrUUID(id)
return VideoModel.findOne(options)
}
- static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) {
- return VideoModel.scope(ScopeNames.WITH_FILES)
+ static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) {
+ return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ])
.findById(id, { transaction: t, logging })
}
}
}
- return VideoModel
- .scope([ ScopeNames.WITH_FILES ])
- .findOne(options)
+ return VideoModel.findOne(options)
}
static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
transaction
}
- return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
+ return VideoModel.scope([
+ ScopeNames.WITH_ACCOUNT_DETAILS,
+ ScopeNames.WITH_FILES,
+ ScopeNames.WITH_STREAMING_PLAYLISTS
+ ]).findOne(query)
}
static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
const scopes = [
ScopeNames.WITH_TAGS,
ScopeNames.WITH_BLACKLISTED,
+ ScopeNames.WITH_ACCOUNT_DETAILS,
+ ScopeNames.WITH_SCHEDULED_UPDATE,
ScopeNames.WITH_FILES,
+ ScopeNames.WITH_STREAMING_PLAYLISTS
+ ]
+
+ if (userId) {
+ scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings
+ }
+
+ return VideoModel
+ .scope(scopes)
+ .findOne(options)
+ }
+
+ static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) {
+ const where = VideoModel.buildWhereIdOrUUID(id)
+
+ const options = {
+ order: [ [ 'Tags', 'name', 'ASC' ] ],
+ where,
+ transaction: t
+ }
+
+ const scopes = [
+ ScopeNames.WITH_TAGS,
+ ScopeNames.WITH_BLACKLISTED,
ScopeNames.WITH_ACCOUNT_DETAILS,
- ScopeNames.WITH_SCHEDULED_UPDATE
+ ScopeNames.WITH_SCHEDULED_UPDATE,
+ { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings
+ { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings
]
if (userId) {
.catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
}
+ removeStreamingPlaylist (isRedundancy = false) {
+ const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_PLAYLIST_DIRECTORY
+
+ const filePath = join(baseDir, this.uuid)
+ return remove(filePath)
+ .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
+ }
+
isOutdated () {
if (this.isOwned()) return false
generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
- const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
+ const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
const redundancies = videoFile.RedundancyVideos
return magnetUtil.encode(magnetHash)
}
+ getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
+ return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
+ }
+
getThumbnailUrl (baseUrlHttp: string) {
return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
}
getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
}
+
+ getBandwidthBits (videoFile: VideoFileModel) {
+ return Math.ceil((videoFile.size * 8) / this.duration)
+ }
}
'480p': true,
'720p': false,
'1080p': false
+ },
+ hls: {
+ enabled: false
}
},
import: {
viewVideo,
wait,
waitUntilLog,
- checkVideoFilesWereRemoved, removeVideo, getVideoWithToken
+ checkVideoFilesWereRemoved, removeVideo, getVideoWithToken, reRunServer
} from '../../../../shared/utils'
import { waitJobs } from '../../../../shared/utils/server/jobs'
async function runServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) {
const config = {
+ transcoding: {
+ hls: {
+ enabled: true
+ }
+ },
redundancy: {
videos: {
check_interval: '5 seconds',
await waitJobs(servers)
}
-async function check1WebSeed (strategy: VideoRedundancyStrategy, videoUUID?: string) {
+async function check1WebSeed (videoUUID?: string) {
if (!videoUUID) videoUUID = video1Server2UUID
const webseeds = [
]
for (const server of servers) {
- {
- // With token to avoid issues with video follow constraints
- const res = await getVideoWithToken(server.url, server.accessToken, videoUUID)
+ // With token to avoid issues with video follow constraints
+ const res = await getVideoWithToken(server.url, server.accessToken, videoUUID)
- const video: VideoDetails = res.body
- for (const f of video.files) {
- checkMagnetWebseeds(f, webseeds, server)
- }
+ const video: VideoDetails = res.body
+ for (const f of video.files) {
+ checkMagnetWebseeds(f, webseeds, server)
}
}
}
-async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) {
- const res = await getStats(servers[0].url)
- const data: ServerStats = res.body
-
- expect(data.videosRedundancy).to.have.lengthOf(1)
- const stat = data.videosRedundancy[0]
-
- expect(stat.strategy).to.equal(strategy)
- expect(stat.totalSize).to.equal(204800)
- expect(stat.totalUsed).to.be.at.least(1).and.below(204801)
- expect(stat.totalVideoFiles).to.equal(4)
- expect(stat.totalVideos).to.equal(1)
-}
-
-async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) {
- const res = await getStats(servers[0].url)
- const data: ServerStats = res.body
-
- expect(data.videosRedundancy).to.have.lengthOf(1)
-
- const stat = data.videosRedundancy[0]
- expect(stat.strategy).to.equal(strategy)
- expect(stat.totalSize).to.equal(204800)
- expect(stat.totalUsed).to.equal(0)
- expect(stat.totalVideoFiles).to.equal(0)
- expect(stat.totalVideos).to.equal(0)
-}
-
-async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: string) {
+async function check2Webseeds (videoUUID?: string) {
if (!videoUUID) videoUUID = video1Server2UUID
const webseeds = [
await makeGetRequest({
url: servers[1].url,
statusCodeExpected: 200,
- path: '/static/webseed/' + `${videoUUID}-${file.resolution.id}.mp4`,
+ path: `/static/webseed/${videoUUID}-${file.resolution.id}.mp4`,
contentType: null
})
}
}
}
+async function check0PlaylistRedundancies (videoUUID?: string) {
+ if (!videoUUID) videoUUID = video1Server2UUID
+
+ for (const server of servers) {
+ // With token to avoid issues with video follow constraints
+ const res = await getVideoWithToken(server.url, server.accessToken, videoUUID)
+ const video: VideoDetails = res.body
+
+ expect(video.streamingPlaylists).to.be.an('array')
+ expect(video.streamingPlaylists).to.have.lengthOf(1)
+ expect(video.streamingPlaylists[0].redundancies).to.have.lengthOf(0)
+ }
+}
+
+async function check1PlaylistRedundancies (videoUUID?: string) {
+ if (!videoUUID) videoUUID = video1Server2UUID
+
+ for (const server of servers) {
+ const res = await getVideo(server.url, videoUUID)
+ const video: VideoDetails = res.body
+
+ expect(video.streamingPlaylists).to.have.lengthOf(1)
+ expect(video.streamingPlaylists[0].redundancies).to.have.lengthOf(1)
+
+ const redundancy = video.streamingPlaylists[0].redundancies[0]
+
+ expect(redundancy.baseUrl).to.equal(servers[0].url + '/static/redundancy/hls/' + videoUUID)
+ }
+
+ await makeGetRequest({
+ url: servers[0].url,
+ statusCodeExpected: 200,
+ path: `/static/redundancy/hls/${videoUUID}/360_000.ts`,
+ contentType: null
+ })
+
+ for (const directory of [ 'test1/redundancy/hls', 'test2/playlists/hls' ]) {
+ const files = await readdir(join(root(), directory, videoUUID))
+ expect(files).to.have.length.at.least(4)
+
+ for (const resolution of [ 240, 360, 480, 720 ]) {
+ expect(files.find(f => f === `${resolution}_000.ts`)).to.not.be.undefined
+ expect(files.find(f => f === `${resolution}_001.ts`)).to.not.be.undefined
+ }
+ }
+}
+
+async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) {
+ const res = await getStats(servers[0].url)
+ const data: ServerStats = res.body
+
+ expect(data.videosRedundancy).to.have.lengthOf(1)
+ const stat = data.videosRedundancy[0]
+
+ expect(stat.strategy).to.equal(strategy)
+ expect(stat.totalSize).to.equal(204800)
+ expect(stat.totalUsed).to.be.at.least(1).and.below(204801)
+ expect(stat.totalVideoFiles).to.equal(4)
+ expect(stat.totalVideos).to.equal(1)
+}
+
+async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) {
+ const res = await getStats(servers[0].url)
+ const data: ServerStats = res.body
+
+ expect(data.videosRedundancy).to.have.lengthOf(1)
+
+ const stat = data.videosRedundancy[0]
+ expect(stat.strategy).to.equal(strategy)
+ expect(stat.totalSize).to.equal(204800)
+ expect(stat.totalUsed).to.equal(0)
+ expect(stat.totalVideoFiles).to.equal(0)
+ expect(stat.totalVideos).to.equal(0)
+}
+
async function enableRedundancyOnServer1 () {
await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true)
})
it('Should have 1 webseed on the first video', async function () {
- await check1WebSeed(strategy)
+ await check1WebSeed()
+ await check0PlaylistRedundancies()
await checkStatsWith1Webseed(strategy)
})
})
it('Should have 2 webseeds on the first video', async function () {
- this.timeout(40000)
+ this.timeout(80000)
await waitJobs(servers)
- await waitUntilLog(servers[0], 'Duplicated ', 4)
+ await waitUntilLog(servers[0], 'Duplicated ', 5)
await waitJobs(servers)
- await check2Webseeds(strategy)
+ await check2Webseeds()
+ await check1PlaylistRedundancies()
await checkStatsWith2Webseed(strategy)
})
it('Should undo redundancy on server 1 and remove duplicated videos', async function () {
- this.timeout(40000)
+ this.timeout(80000)
await disableRedundancyOnServer1()
await waitJobs(servers)
await wait(5000)
- await check1WebSeed(strategy)
+ await check1WebSeed()
+ await check0PlaylistRedundancies()
- await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ])
+ await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos', join('playlists', 'hls') ])
})
after(function () {
})
it('Should have 1 webseed on the first video', async function () {
- await check1WebSeed(strategy)
+ await check1WebSeed()
+ await check0PlaylistRedundancies()
await checkStatsWith1Webseed(strategy)
})
})
it('Should have 2 webseeds on the first video', async function () {
- this.timeout(40000)
+ this.timeout(80000)
await waitJobs(servers)
- await waitUntilLog(servers[0], 'Duplicated ', 4)
+ await waitUntilLog(servers[0], 'Duplicated ', 5)
await waitJobs(servers)
- await check2Webseeds(strategy)
+ await check2Webseeds()
+ await check1PlaylistRedundancies()
await checkStatsWith2Webseed(strategy)
})
it('Should unfollow on server 1 and remove duplicated videos', async function () {
- this.timeout(40000)
+ this.timeout(80000)
await unfollow(servers[0].url, servers[0].accessToken, servers[1])
await waitJobs(servers)
await wait(5000)
- await check1WebSeed(strategy)
+ await check1WebSeed()
+ await check0PlaylistRedundancies()
await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ])
})
})
it('Should have 1 webseed on the first video', async function () {
- await check1WebSeed(strategy)
+ await check1WebSeed()
+ await check0PlaylistRedundancies()
await checkStatsWith1Webseed(strategy)
})
})
it('Should still have 1 webseed on the first video', async function () {
- this.timeout(40000)
+ this.timeout(80000)
await waitJobs(servers)
await wait(15000)
await waitJobs(servers)
- await check1WebSeed(strategy)
+ await check1WebSeed()
+ await check0PlaylistRedundancies()
await checkStatsWith1Webseed(strategy)
})
it('Should view 2 times the first video to have > min_views config', async function () {
- this.timeout(40000)
+ this.timeout(80000)
await viewVideo(servers[ 0 ].url, video1Server2UUID)
await viewVideo(servers[ 2 ].url, video1Server2UUID)
})
it('Should have 2 webseeds on the first video', async function () {
- this.timeout(40000)
+ this.timeout(80000)
await waitJobs(servers)
- await waitUntilLog(servers[0], 'Duplicated ', 4)
+ await waitUntilLog(servers[0], 'Duplicated ', 5)
await waitJobs(servers)
- await check2Webseeds(strategy)
+ await check2Webseeds()
+ await check1PlaylistRedundancies()
await checkStatsWith2Webseed(strategy)
})
})
it('Should still have 2 webseeds after 10 seconds', async function () {
- this.timeout(40000)
+ this.timeout(80000)
await wait(10000)
})
it('Should stop server 1 and expire video redundancy', async function () {
- this.timeout(40000)
+ this.timeout(80000)
killallServers([ servers[0] ])
await enableRedundancyOnServer1()
await waitJobs(servers)
- await waitUntilLog(servers[0], 'Duplicated ', 4)
+ await waitUntilLog(servers[0], 'Duplicated ', 5)
await waitJobs(servers)
- await check2Webseeds(strategy)
+ await check2Webseeds()
+ await check1PlaylistRedundancies()
await checkStatsWith2Webseed(strategy)
const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' })
await wait(1000)
try {
- await check1WebSeed(strategy, video1Server2UUID)
- await check2Webseeds(strategy, video2Server2UUID)
+ await check1WebSeed(video1Server2UUID)
+ await check0PlaylistRedundancies(video1Server2UUID)
+ await check2Webseeds(video2Server2UUID)
+ await check1PlaylistRedundancies(video2Server2UUID)
checked = true
} catch {
}
})
+ it('Should disable strategy and remove redundancies', async function () {
+ this.timeout(80000)
+
+ await waitJobs(servers)
+
+ killallServers([ servers[ 0 ] ])
+ await reRunServer(servers[ 0 ], {
+ redundancy: {
+ videos: {
+ check_interval: '1 second',
+ strategies: []
+ }
+ }
+ })
+
+ await waitJobs(servers)
+
+ await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ join('redundancy', 'hls') ])
+ })
+
after(function () {
return cleanServers()
})
expect(data.transcoding.resolutions['480p']).to.be.true
expect(data.transcoding.resolutions['720p']).to.be.true
expect(data.transcoding.resolutions['1080p']).to.be.true
+ expect(data.transcoding.hls.enabled).to.be.true
+
expect(data.import.videos.http.enabled).to.be.true
expect(data.import.videos.torrent.enabled).to.be.true
}
expect(data.transcoding.resolutions['480p']).to.be.true
expect(data.transcoding.resolutions['720p']).to.be.false
expect(data.transcoding.resolutions['1080p']).to.be.false
+ expect(data.transcoding.hls.enabled).to.be.false
expect(data.import.videos.http.enabled).to.be.false
expect(data.import.videos.torrent.enabled).to.be.false
'480p': true,
'720p': false,
'1080p': false
+ },
+ hls: {
+ enabled: false
}
},
import: {
import './video-channels'
import './video-comments'
import './video-description'
+import './video-hls'
import './video-imports'
import './video-nsfw'
import './video-privacy'
--- /dev/null
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+ checkDirectoryIsEmpty,
+ checkTmpIsEmpty,
+ doubleFollow,
+ flushAndRunMultipleServers,
+ flushTests,
+ getPlaylist,
+ getSegment,
+ getSegmentSha256,
+ getVideo,
+ killallServers,
+ removeVideo,
+ ServerInfo,
+ setAccessTokensToServers,
+ updateVideo,
+ uploadVideo,
+ waitJobs
+} from '../../../../shared/utils'
+import { VideoDetails } from '../../../../shared/models/videos'
+import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type'
+import { sha256 } from '../../../helpers/core-utils'
+import { join } from 'path'
+
+const expect = chai.expect
+
+async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) {
+ const resolutions = [ 240, 360, 480, 720 ]
+
+ for (const server of servers) {
+ const res = await getVideo(server.url, videoUUID)
+ const videoDetails: VideoDetails = res.body
+
+ expect(videoDetails.streamingPlaylists).to.have.lengthOf(1)
+
+ const hlsPlaylist = videoDetails.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
+ expect(hlsPlaylist).to.not.be.undefined
+
+ {
+ const res2 = await getPlaylist(hlsPlaylist.playlistUrl)
+
+ const masterPlaylist = res2.text
+
+ expect(masterPlaylist).to.contain('#EXT-X-STREAM-INF:BANDWIDTH=55472,RESOLUTION=640x360,FRAME-RATE=25')
+
+ for (const resolution of resolutions) {
+ expect(masterPlaylist).to.contain(`${resolution}.m3u8`)
+ }
+ }
+
+ {
+ for (const resolution of resolutions) {
+ const res2 = await getPlaylist(`http://localhost:9001/static/playlists/hls/${videoUUID}/${resolution}.m3u8`)
+
+ const subPlaylist = res2.text
+ expect(subPlaylist).to.contain(resolution + '_000.ts')
+ }
+ }
+
+ {
+ for (const resolution of resolutions) {
+
+ const res2 = await getSegment(`http://localhost:9001/static/playlists/hls/${videoUUID}/${resolution}_000.ts`)
+
+ const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url)
+
+ const sha256Server = resSha.body[ resolution + '_000.ts' ]
+ expect(sha256(res2.body)).to.equal(sha256Server)
+ }
+ }
+ }
+}
+
+describe('Test HLS videos', function () {
+ let servers: ServerInfo[] = []
+ let videoUUID = ''
+
+ before(async function () {
+ this.timeout(120000)
+
+ servers = await flushAndRunMultipleServers(2, { transcoding: { enabled: true, hls: { enabled: true } } })
+
+ // Get the access tokens
+ await setAccessTokensToServers(servers)
+
+ // Server 1 and server 2 follow each other
+ await doubleFollow(servers[0], servers[1])
+ })
+
+ it('Should upload a video and transcode it to HLS', async function () {
+ this.timeout(120000)
+
+ {
+ const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video 1', fixture: 'video_short.webm' })
+ videoUUID = res.body.video.uuid
+ }
+
+ await waitJobs(servers)
+
+ await checkHlsPlaylist(servers, videoUUID)
+ })
+
+ it('Should update the video', async function () {
+ await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, { name: 'video 1 updated' })
+
+ await waitJobs(servers)
+
+ await checkHlsPlaylist(servers, videoUUID)
+ })
+
+ it('Should delete the video', async function () {
+ await removeVideo(servers[0].url, servers[0].accessToken, videoUUID)
+
+ await waitJobs(servers)
+
+ for (const server of servers) {
+ await getVideo(server.url, videoUUID, 404)
+ }
+ })
+
+ it('Should have the playlists/segment deleted from the disk', async function () {
+ for (const server of servers) {
+ await checkDirectoryIsEmpty(server, 'videos')
+ await checkDirectoryIsEmpty(server, join('playlists', 'hls'))
+ }
+ })
+
+ it('Should have an empty tmp directory', async function () {
+ for (const server of servers) {
+ await checkTmpIsEmpty(server)
+ }
+ })
+
+ after(async function () {
+ killallServers(servers)
+
+ // Keep the logs if the test failed
+ if (this['ok']) {
+ await flushTests()
+ }
+ })
+})
const { body } = await makeActivityPubGetRequest(server.url, '/videos/watch/' + video.uuid)
expect(body.id).to.equal('http://localhost:9002/videos/watch/' + video.uuid)
+
+ const res = await getVideo(server.url, video.uuid)
+ const videoDetails: VideoDetails = res.body
+
+ expect(videoDetails.trackerUrls[0]).to.include(server.host)
+ expect(videoDetails.streamingPlaylists[0].playlistUrl).to.include(server.host)
+ expect(videoDetails.streamingPlaylists[0].segmentsSha256Url).to.include(server.host)
}
})
}
})
- it('Should have update accounts url', async function () {
+ it('Should have updated accounts url', async function () {
const res = await getAccountsList(server.url)
expect(res.body.total).to.equal(3)
}
})
- it('Should update torrent hosts', async function () {
+ it('Should have updated torrent hosts', async function () {
this.timeout(30000)
const res = await getVideosList(server.url)
-import { ActivityVideoUrlObject } from './common-objects'
+import { ActivityVideoUrlObject, ActivityPlaylistUrlObject } from './common-objects'
export interface CacheFileObject {
id: string
type: 'CacheFile',
object: string
expires: string
- url: ActivityVideoUrlObject
+ url: ActivityVideoUrlObject | ActivityPlaylistUrlObject
}
fps: number
}
-export type ActivityUrlObject =
- ActivityVideoUrlObject
- |
- {
- type: 'Link'
- // TODO: remove mimeType (backward compatibility, introduced in v1.1.0)
- mimeType?: 'application/x-bittorrent' | 'application/x-bittorrent;x-scheme-handler/magnet'
- mediaType: 'application/x-bittorrent' | 'application/x-bittorrent;x-scheme-handler/magnet'
- href: string
- height: number
- }
- |
- {
- type: 'Link'
- // TODO: remove mimeType (backward compatibility, introduced in v1.1.0)
- mimeType?: 'text/html'
- mediaType: 'text/html'
- href: string
- }
+export type ActivityPlaylistSegmentHashesObject = {
+ type: 'Link'
+ name: 'sha256'
+ // TODO: remove mimeType (backward compatibility, introduced in v1.1.0)
+ mimeType?: 'application/json'
+ mediaType: 'application/json'
+ href: string
+}
+
+export type ActivityPlaylistInfohashesObject = {
+ type: 'Infohash'
+ name: string
+}
+
+export type ActivityPlaylistUrlObject = {
+ type: 'Link'
+ // TODO: remove mimeType (backward compatibility, introduced in v1.1.0)
+ mimeType?: 'application/x-mpegURL'
+ mediaType: 'application/x-mpegURL'
+ href: string
+ tag?: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[]
+}
+
+export type ActivityBitTorrentUrlObject = {
+ type: 'Link'
+ // TODO: remove mimeType (backward compatibility, introduced in v1.1.0)
+ mimeType?: 'application/x-bittorrent' | 'application/x-bittorrent;x-scheme-handler/magnet'
+ mediaType: 'application/x-bittorrent' | 'application/x-bittorrent;x-scheme-handler/magnet'
+ href: string
+ height: number
+}
+
+export type ActivityHtmlUrlObject = {
+ type: 'Link'
+ // TODO: remove mimeType (backward compatibility, introduced in v1.1.0)
+ mimeType?: 'text/html'
+ mediaType: 'text/html'
+ href: string
+}
+
+export type ActivityUrlObject = ActivityVideoUrlObject | ActivityPlaylistUrlObject | ActivityBitTorrentUrlObject | ActivityHtmlUrlObject
export interface ActivityPubAttributedTo {
type: 'Group' | 'Person'
'720p': boolean
'1080p': boolean
}
+ hls: {
+ enabled: boolean
+ }
}
import: {
signup: {
allowed: boolean,
- allowedForCurrentIP: boolean,
+ allowedForCurrentIP: boolean
requiresEmailVerification: boolean
}
transcoding: {
+ hls: {
+ enabled: boolean
+ }
+
enabledResolutions: number[]
}
file: {
size: {
max: number
- },
+ }
extensions: string[]
}
}
--- /dev/null
+import { VideoStreamingPlaylistType } from './video-streaming-playlist.type'
+
+export class VideoStreamingPlaylist {
+ id: number
+ type: VideoStreamingPlaylistType
+ playlistUrl: string
+ segmentsSha256Url: string
+
+ redundancies: {
+ baseUrl: string
+ }[]
+}
--- /dev/null
+export enum VideoStreamingPlaylistType {
+ HLS = 1
+}
import { VideoPrivacy } from './video-privacy.enum'
import { VideoScheduleUpdate } from './video-schedule-update.model'
import { VideoConstant } from './video-constant.model'
+import { VideoStreamingPlaylist } from './video-streaming-playlist.model'
export interface VideoFile {
magnetUri: string
// Not optional in details (unlike in Video)
waitTranscoding: boolean
state: VideoConstant<VideoState>
+
+ trackerUrls: string[]
+
+ streamingPlaylists: VideoStreamingPlaylist[]
}
export * from './videos/video-abuses'
export * from './videos/video-blacklist'
export * from './videos/video-channels'
+export * from './videos/video-comments'
+export * from './videos/video-playlists'
export * from './videos/videos'
export * from './videos/video-change-ownership'
export * from './feeds/feeds'
import * as request from 'supertest'
import { buildAbsoluteFixturePath, root } from '../miscs/miscs'
import { isAbsolute, join } from 'path'
+import { parse } from 'url'
+
+function makeRawRequest (url: string, statusCodeExpected?: number) {
+ const { host, protocol, pathname } = parse(url)
+
+ return makeGetRequest({ url: `${protocol}//${host}`, path: pathname, statusCodeExpected })
+}
function makeGetRequest (options: {
url: string,
- path: string,
+ path?: string,
query?: any,
token?: string,
statusCodeExpected?: number,
if (!options.statusCodeExpected) options.statusCodeExpected = 400
if (options.contentType === undefined) options.contentType = 'application/json'
- const req = request(options.url)
- .get(options.path)
+ const req = request(options.url).get(options.path)
if (options.contentType) req.set('Accept', options.contentType)
if (options.token) req.set('Authorization', 'Bearer ' + options.token)
makePostBodyRequest,
makePutBodyRequest,
makeDeleteRequest,
+ makeRawRequest,
updateAvatarRequest
}
'480p': true,
'720p': false,
'1080p': false
+ },
+ hls: {
+ enabled: false
}
},
import: {
}
async function checkTmpIsEmpty (server: ServerInfo) {
+ return checkDirectoryIsEmpty(server, 'tmp')
+}
+
+async function checkDirectoryIsEmpty (server: ServerInfo, directory: string) {
const testDirectory = 'test' + server.serverNumber
- const directoryPath = join(root(), testDirectory, 'tmp')
+ const directoryPath = join(root(), testDirectory, directory)
const directoryExists = existsSync(directoryPath)
expect(directoryExists).to.be.true
// ---------------------------------------------------------------------------
export {
+ checkDirectoryIsEmpty,
checkTmpIsEmpty,
ServerInfo,
flushAndRunMultipleServers,
--- /dev/null
+import { makeRawRequest } from '../requests/requests'
+
+function getPlaylist (url: string, statusCodeExpected = 200) {
+ return makeRawRequest(url, statusCodeExpected)
+}
+
+function getSegment (url: string, statusCodeExpected = 200) {
+ return makeRawRequest(url, statusCodeExpected)
+}
+
+function getSegmentSha256 (url: string, statusCodeExpected = 200) {
+ return makeRawRequest(url, statusCodeExpected)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ getPlaylist,
+ getSegment,
+ getSegmentSha256
+}
async function checkVideoFilesWereRemoved (
videoUUID: string,
serverNumber: number,
- directories = [ 'redundancy', 'videos', 'thumbnails', 'torrents', 'previews', 'captions' ]
+ directories = [
+ 'redundancy',
+ 'videos',
+ 'thumbnails',
+ 'torrents',
+ 'previews',
+ 'captions',
+ join('playlists', 'hls'),
+ join('redundancy', 'hls')
+ ]
) {
const testDirectory = 'test' + serverNumber
const directoryPath = join(root(), testDirectory, directory)
const directoryExists = existsSync(directoryPath)
- expect(directoryExists).to.be.true
+ if (!directoryExists) continue
const files = await readdir(directoryPath)
for (const file of files) {
# yarn lockfile v1
+"@babel/polyfill@^7.2.5":
+ version "7.2.5"
+ resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.2.5.tgz#6c54b964f71ad27edddc567d065e57e87ed7fa7d"
+ integrity sha512-8Y/t3MWThtMLYr0YNC/Q76tqN1w30+b0uQMeFUYauG2UGTR19zyUtFrAzT23zNtBxPp+LbE5E/nwV/q/r3y6ug==
+ dependencies:
+ core-js "^2.5.7"
+ regenerator-runtime "^0.12.0"
+
"@iamstarkov/listr-update-renderer@0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@iamstarkov/listr-update-renderer/-/listr-update-renderer-0.4.1.tgz#d7c48092a2dcf90fd672b6c8b458649cb350c77e"
resolved "https://registry.yarnpkg.com/hide-powered-by/-/hide-powered-by-1.0.0.tgz#4a85ad65881f62857fc70af7174a1184dccce32b"
integrity sha1-SoWtZYgfYoV/xwr3F0oRhNzM4ys=
+"hlsdownloader@https://github.com/Chocobozzz/hlsdownloader#build":
+ version "0.0.0-semantic-release"
+ resolved "https://github.com/Chocobozzz/hlsdownloader#e19f9d803dcfe7ec25fd734b4743184f19a9b0cc"
+ dependencies:
+ "@babel/polyfill" "^7.2.5"
+ async "^2.6.1"
+ minimist "^1.2.0"
+ mkdirp "^0.5.1"
+ request "^2.88.0"
+ request-promise "^4.2.2"
+
hosted-git-info@^2.1.4, hosted-git-info@^2.6.0, hosted-git-info@^2.7.1:
version "2.7.1"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=
-lodash@^4.0.0, lodash@^4.17.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.5, lodash@^4.3.0, lodash@^4.8.2, lodash@~4.17.10:
+lodash@^4.0.0, lodash@^4.13.1, lodash@^4.17.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.5, lodash@^4.3.0, lodash@^4.8.2, lodash@~4.17.10:
version "4.17.11"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==
resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.29.tgz#60f580d360170bb722a797cc704411e6da850c67"
integrity sha512-AeUmQ0oLN02flVHXWh9sSJF7mcdFq0ppid/JkErufc3hGIV/AMa8Fo9VgDo/cT2jFdOWoFvHp90qqBH54W+gjQ==
+psl@^1.1.28:
+ version "1.1.31"
+ resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184"
+ integrity sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw==
+
pstree.remy@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.2.tgz#4448bbeb4b2af1fed242afc8dc7416a6f504951a"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
-punycode@^2.1.0:
+punycode@^2.1.0, punycode@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.12.tgz#311bf0c6b63cd782f228a81abe146a2bfa9c56f2"
integrity sha512-n+IyV+nGz3+0q3/Yf1ra12KpCyi001bi4XFxSjbiWWjfqb52iTTtpGXmCCAOWWIAn9KEuFZKGqBERHmrtScZ3A==
+regenerator-runtime@^0.12.0:
+ version "0.12.1"
+ resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
+ integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
+
regex-not@^1.0.0, regex-not@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
+request-promise-core@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.1.tgz#3eee00b2c5aa83239cfb04c5700da36f81cd08b6"
+ integrity sha1-Pu4AssWqgyOc+wTFcA2jb4HNCLY=
+ dependencies:
+ lodash "^4.13.1"
+
+request-promise@^4.2.2:
+ version "4.2.2"
+ resolved "https://registry.yarnpkg.com/request-promise/-/request-promise-4.2.2.tgz#d1ea46d654a6ee4f8ee6a4fea1018c22911904b4"
+ integrity sha1-0epG1lSm7k+O5qT+oQGMIpEZBLQ=
+ dependencies:
+ bluebird "^3.5.0"
+ request-promise-core "1.1.1"
+ stealthy-require "^1.1.0"
+ tough-cookie ">=2.3.3"
+
request@^2.74.0, request@^2.81.0, request@^2.83.0, request@^2.87.0, request@^2.88.0:
version "2.88.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==
+stealthy-require@^1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
+ integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=
+
stream-each@^1.1.0:
version "1.2.3"
resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae"
dependencies:
nopt "~1.0.10"
+tough-cookie@>=2.3.3:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-3.0.1.tgz#9df4f57e739c26930a018184887f4adb7dca73b2"
+ integrity sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==
+ dependencies:
+ ip-regex "^2.1.0"
+ psl "^1.1.28"
+ punycode "^2.1.1"
+
tough-cookie@~2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"