Add hls support on server
authorChocobozzz <me@florianbigard.com>
Tue, 29 Jan 2019 07:37:25 +0000 (08:37 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Mon, 11 Feb 2019 08:13:02 +0000 (09:13 +0100)
83 files changed:
client/package.json
client/src/app/+admin/users/user-edit/user-edit.ts
client/src/app/core/server/server.service.ts
client/src/app/shared/video/video-details.model.ts
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/assets/player/p2p-media-loader-plugin.ts [deleted file]
client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts [new file with mode: 0644]
client/src/assets/player/p2p-media-loader/segment-url-builder.ts [new file with mode: 0644]
client/src/assets/player/p2p-media-loader/segment-validator.ts [new file with mode: 0644]
client/src/assets/player/peertube-player-manager.ts
client/src/assets/player/peertube-plugin.ts
client/src/assets/player/peertube-videojs-typings.ts
client/src/assets/player/utils.ts
client/src/assets/player/videojs-components/p2p-info-button.ts
client/src/assets/player/webtorrent-plugin.ts [deleted file]
client/src/assets/player/webtorrent/webtorrent-plugin.ts [new file with mode: 0644]
client/src/standalone/videos/embed.ts
client/yarn.lock
config/default.yaml
config/production.yaml.example
config/test-1.yaml
config/test-2.yaml
config/test-3.yaml
config/test-4.yaml
config/test-5.yaml
config/test-6.yaml
config/test.yaml
package.json
scripts/i18n/create-custom-files.ts
scripts/update-host.ts
server/controllers/activitypub/client.ts
server/controllers/api/config.ts
server/controllers/api/videos/index.ts
server/controllers/static.ts
server/controllers/tracker.ts
server/helpers/activitypub.ts
server/helpers/core-utils.ts
server/helpers/custom-validators/activitypub/cache-file.ts
server/helpers/custom-validators/activitypub/videos.ts
server/helpers/custom-validators/misc.ts
server/helpers/ffmpeg-utils.ts
server/helpers/video.ts
server/initializers/checker-before-init.ts
server/initializers/constants.ts
server/initializers/database.ts
server/initializers/installer.ts
server/initializers/migrations/0330-video-streaming-playlist.ts [new file with mode: 0644]
server/lib/activitypub/cache-file.ts
server/lib/activitypub/send/send-create.ts
server/lib/activitypub/send/send-undo.ts
server/lib/activitypub/send/send-update.ts
server/lib/activitypub/url.ts
server/lib/activitypub/videos.ts
server/lib/hls.ts [new file with mode: 0644]
server/lib/job-queue/handlers/video-file.ts
server/lib/schedulers/videos-redundancy-scheduler.ts
server/lib/video-transcoding.ts
server/middlewares/validators/redundancy.ts
server/models/redundancy/video-redundancy.ts
server/models/video/video-file.ts
server/models/video/video-format-utils.ts
server/models/video/video-streaming-playlist.ts [new file with mode: 0644]
server/models/video/video.ts
server/tests/api/check-params/config.ts
server/tests/api/redundancy/redundancy.ts
server/tests/api/server/config.ts
server/tests/api/videos/index.ts
server/tests/api/videos/video-hls.ts [new file with mode: 0644]
server/tests/cli/update-host.ts
shared/models/activitypub/objects/cache-file-object.ts
shared/models/activitypub/objects/common-objects.ts
shared/models/server/custom-config.model.ts
shared/models/server/server-config.model.ts
shared/models/videos/video-streaming-playlist.model.ts [new file with mode: 0644]
shared/models/videos/video-streaming-playlist.type.ts [new file with mode: 0644]
shared/models/videos/video.model.ts
shared/utils/index.ts
shared/utils/requests/requests.ts
shared/utils/server/config.ts
shared/utils/server/servers.ts
shared/utils/videos/video-playlists.ts [new file with mode: 0644]
shared/utils/videos/videos.ts
yarn.lock

index a455653fe4a3e8be6a8aaa6ce25913978a623a49..342bab00dd995c48d936dca423c5ea6718f4a847 100644 (file)
     "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",
index 0b3511e8eb969af4ac03e2f65d4f40a2fd4d698f..021b1feb4ed11aff3b492f4887bee1b8116bbde0 100644 (file)
@@ -22,7 +22,9 @@ export abstract class UserEdit extends FormReactive {
   }
 
   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
 
@@ -30,6 +32,8 @@ export abstract class UserEdit extends FormReactive {
       multiplier += resolution / higherResolution
     }
 
+    if (transcodingConfig.hls.enabled) multiplier *= 2
+
     return multiplier * parseInt(this.form.value['videoQuota'], 10)
   }
 
index 4ae72427b9ecf8c7b727ad38c12cdd55e00a1171..c868ccdcc26abc8e6a746234398fa6bab5339ed4 100644 (file)
@@ -51,7 +51,10 @@ export class ServerService {
       requiresEmailVerification: false
     },
     transcoding: {
-      enabledResolutions: []
+      enabledResolutions: [],
+      hls: {
+        enabled: false
+      }
     },
     avatar: {
       file: {
index fa4ca7f9391014a7729dbcbfe6c05a2fc902ab58..f44b4138b2b8375ebf0ea2531a8efbc374ed93ea 100644 (file)
@@ -3,6 +3,8 @@ import { AuthUser } from '../../core'
 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
@@ -19,6 +21,10 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
   likesPercent: number
   dislikesPercent: number
 
+  trackerUrls: string[]
+
+  streamingPlaylists: VideoStreamingPlaylist[]
+
   constructor (hash: VideoDetailsServerModel, translations = {}) {
     super(hash, translations)
 
@@ -30,6 +36,9 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
     this.support = hash.support
     this.commentsEnabled = hash.commentsEnabled
 
+    this.trackerUrls = hash.trackerUrls
+    this.streamingPlaylists = hash.streamingPlaylists
+
     this.buildLikeAndDislikePercents()
   }
 
@@ -53,4 +62,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
     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)
+  }
 }
index 6e38af19593a0b9f085ee7002b3a5ac1d0b48741..f77316712ee44b2e8c86dee473ba8f8fad534137 100644 (file)
@@ -23,7 +23,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
 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',
@@ -424,15 +424,33 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
         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))
     })
 
diff --git a/client/src/assets/player/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader-plugin.ts
deleted file mode 100644 (file)
index a5b2021..0000000
+++ /dev/null
@@ -1,104 +0,0 @@
-// 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 }
diff --git a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
new file mode 100644 (file)
index 0000000..f9a2707
--- /dev/null
@@ -0,0 +1,135 @@
+// 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 }
diff --git a/client/src/assets/player/p2p-media-loader/segment-url-builder.ts b/client/src/assets/player/p2p-media-loader/segment-url-builder.ts
new file mode 100644 (file)
index 0000000..32e7ce4
--- /dev/null
@@ -0,0 +1,28 @@
+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))
+}
diff --git a/client/src/assets/player/p2p-media-loader/segment-validator.ts b/client/src/assets/player/p2p-media-loader/segment-validator.ts
new file mode 100644 (file)
index 0000000..8f4922d
--- /dev/null
@@ -0,0 +1,56 @@
+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
+}
index 91ca6a2aafdd1d8b11859d14cda6fa6cb4a37511..3fdba6fdf207f32a93ba5164365ea6c0aa4607db 100644 (file)
@@ -13,8 +13,10 @@ import './videojs-components/p2p-info-button'
 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'
@@ -31,7 +33,10 @@ export type WebtorrentOptions = {
 
 export type P2PMediaLoaderOptions = {
   playlistUrl: string
+  segmentsSha256Url: string
   trackerAnnounce: string[]
+  redundancyBaseUrls: string[]
+  videoFiles: VideoFile[]
 }
 
 export type CommonOptions = {
@@ -90,11 +95,11 @@ export class PeertubePlayerManager {
   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')
       ])
     }
 
@@ -144,11 +149,14 @@ export class PeertubePlayerManager {
     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,
@@ -160,19 +168,35 @@ export class PeertubePlayerManager {
 
     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,
@@ -187,12 +211,15 @@ export class PeertubePlayerManager {
 
     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 = {
@@ -208,7 +235,7 @@ export class PeertubePlayerManager {
         : 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,
index f83d9094a40124763806a493aa8306859e8bcc33..aacbf5f6efb5b1e09761730dd80423622ddfdb73 100644 (file)
@@ -52,12 +52,12 @@ class PeerTubePlugin extends Plugin {
     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))
       }
 
index fff992a6f37985a42ea3389aa76760fc145d6eaa..79a5a6c4d6c7c8bd2aec7baad21d4e4fa5e89ddf 100644 (file)
@@ -4,12 +4,15 @@ import * as videojs from 'video.js'
 
 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
   }
 }
 
@@ -33,6 +36,8 @@ type UserWatching = {
 }
 
 type PeerTubePluginOptions = {
+  mode: PlayerMode
+
   autoplay: boolean
   videoViewUrl: string
   videoDuration: number
@@ -54,6 +59,7 @@ type WebtorrentPluginOptions = {
 }
 
 type P2PMediaLoaderPluginOptions = {
+  redundancyBaseUrls: string[]
   type: string
   src: string
 }
@@ -91,6 +97,13 @@ type AutoResolutionUpdateData = {
 }
 
 type PlayerNetworkInfo = {
+  http: {
+    downloadSpeed: number
+    uploadSpeed: number
+    downloaded: number
+    uploaded: number
+  }
+
   p2p: {
     downloadSpeed: number
     uploadSpeed: number
index 8b9f34b994033f628b41245287a22836ca648b35..8d87567c2b53dd75b412f8080c7c6c92d181571f 100644 (file)
@@ -112,9 +112,23 @@ function videoFileMinByResolution (files: VideoFile[]) {
   return min
 }
 
+function getRtcConfig () {
+  return {
+    iceServers: [
+      {
+        urls: 'stun:stun.stunprotocol.org'
+      },
+      {
+        urls: 'stun:stun.framasoft.org'
+      }
+    ]
+  }
+}
+
 // ---------------------------------------------------------------------------
 
 export {
+  getRtcConfig,
   toTitleCase,
   timeToInt,
   buildVideoLink,
index 2fc4c456207a8c61e539175af88df184ea568234..6424787b286513740d363544fae2fdcc3267f773 100644 (file)
@@ -75,11 +75,12 @@ class P2pInfoButton extends Button {
       }
 
       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' +
@@ -92,7 +93,7 @@ class P2pInfoButton extends Button {
       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'
diff --git a/client/src/assets/player/webtorrent-plugin.ts b/client/src/assets/player/webtorrent-plugin.ts
deleted file mode 100644 (file)
index 47f169e..0000000
+++ /dev/null
@@ -1,642 +0,0 @@
-// 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 }
diff --git a/client/src/assets/player/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts
new file mode 100644 (file)
index 0000000..c69bf31
--- /dev/null
@@ -0,0 +1,639 @@
+// 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 }
index 6dd9a3d765ffda850c926e7f9fff31f7e4d6711c..1e58d42d9a1030b254469bb29bc0674adaf7d4e7 100644 (file)
@@ -23,7 +23,13 @@ import { peertubeTranslate, ResultList, VideoDetails } from '../../../../shared'
 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
@@ -319,13 +325,16 @@ class PeerTubeEmbed {
     }
 
     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, {
index ced35688f80a736a12f7ead23066119daf9b0f28..06352908e0771baa71d6a6c6b8f46d090cbba32b 100644 (file)
@@ -2641,6 +2641,13 @@ debug@^3.1.0, debug@^3.2.5:
   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"
@@ -6131,7 +6138,7 @@ m3u8-parser@4.2.0:
   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==
@@ -7244,25 +7251,25 @@ p-try@^2.0.0:
   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"
index e16b8c35298d76eb3c5a7d26d22bbddb03e4be95..ad0e6084bd70a3e6d957b87bcd5100cd5fab442f 100644 (file)
@@ -48,6 +48,7 @@ storage:
   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/'
@@ -138,6 +139,14 @@ transcoding:
     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...)
index 661eac0d5ea19f38daaa796ecf02617bfc92c5b6..98734bab614fd02f3ffac5212a809b97346838fe 100644 (file)
@@ -49,6 +49,7 @@ storage:
   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/'
@@ -151,6 +152,14 @@ transcoding:
     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...)
index 8f4f66d2a9c24e9ea79f41c6dd48baab4c6ff8a3..fb69818f39d8052373b25a5bf720cdcbca35f6a1 100644 (file)
@@ -13,6 +13,7 @@ storage:
   tmp: 'test1/tmp/'
   avatars: 'test1/avatars/'
   videos: 'test1/videos/'
+  playlists: 'test1/playlists/'
   redundancy: 'test1/redundancy/'
   logs: 'test1/logs/'
   previews: 'test1/previews/'
index b6d319394a8ed308d45629f60c99d44ee5749c4e..5caddaaa8c9a8c5b3e4071e9e89cc870cafb1da7 100644 (file)
@@ -13,6 +13,7 @@ storage:
   tmp: 'test2/tmp/'
   avatars: 'test2/avatars/'
   videos: 'test2/videos/'
+  playlists: 'test2/playlists/'
   redundancy: 'test2/redundancy/'
   logs: 'test2/logs/'
   previews: 'test2/previews/'
index 934401eb07ed5e01ac7af6f9868e92bbba6a6af6..fac7ebee1d9ebd91af5b1bc26909719b4349367f 100644 (file)
@@ -13,6 +13,7 @@ storage:
   tmp: 'test3/tmp/'
   avatars: 'test3/avatars/'
   videos: 'test3/videos/'
+  playlists: 'test3/playlists/'
   redundancy: 'test3/redundancy/'
   logs: 'test3/logs/'
   previews: 'test3/previews/'
index ee99b250be4013f3061d0d1529017beca48e0a9d..33033773a5901f23800b02579e132c45fae2d1e6 100644 (file)
@@ -13,6 +13,7 @@ storage:
   tmp: 'test4/tmp/'
   avatars: 'test4/avatars/'
   videos: 'test4/videos/'
+  playlists: 'test4/playlists/'
   redundancy: 'test4/redundancy/'
   logs: 'test4/logs/'
   previews: 'test4/previews/'
index e2662bdd930a44ee5232ad122f6d59a658860f9f..d365b6f2ba026bdb6fbb57a894da597e82f94c95 100644 (file)
@@ -13,6 +13,7 @@ storage:
   tmp: 'test5/tmp/'
   avatars: 'test5/avatars/'
   videos: 'test5/videos/'
+  playlists: 'test5/playlists/'
   redundancy: 'test5/redundancy/'
   logs: 'test5/logs/'
   previews: 'test5/previews/'
index ad39c6a9fd1584ea4e728bef635d29b0021e0196..44541c00378b753490d8d44443e39b35ef4d9844 100644 (file)
@@ -13,6 +13,7 @@ storage:
   tmp: 'test6/tmp/'
   avatars: 'test6/avatars/'
   videos: 'test6/videos/'
+  playlists: 'test6/playlists/'
   redundancy: 'test6/redundancy/'
   logs: 'test6/logs/'
   previews: 'test6/previews/'
index aba5dd73c58ec1e28d8dd21754dd5714355ccc8f..6825308405a497ba31d72241dea95af0b21c2466 100644 (file)
@@ -62,6 +62,8 @@ transcoding:
     480p: true
     720p: true
     1080p: true
+  hls:
+    enabled: true
 
 import:
   videos:
index 0cf39c7ee4c5e4f51c2249317458398728ffd3f5..c8c9e64aed30db031e550d2db256b878d3e61383 100644 (file)
     "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",
index ab28f94c8859c965920dda186282857e3cd4f50a..664207e1c91a3805dfb180ee537ca903c8c9050d 100755 (executable)
@@ -23,12 +23,15 @@ const playerKeys = {
   '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'),
index 422a3c9a7df16926e3c0724fa74232780f2ffe8a..64eba867a4bae8769c32a4b5758f54c2288b86c3 100755 (executable)
@@ -13,6 +13,7 @@ import { VideoCommentModel } from '../server/models/video/video-comment'
 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))
@@ -109,11 +110,9 @@ async function run () {
 
   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()
@@ -122,5 +121,12 @@ async function run () {
       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()
+    }
   }
 }
index 1a4e28dc8856e26a3b0e5bf24fe74775ced4a402..32a83aa5f6b614bd05dfcb9d69cf2723cb0f892e 100644 (file)
@@ -37,7 +37,7 @@ import {
   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'
 
@@ -66,11 +66,11 @@ activityPubClientRouter.get('/accounts?/:name/dislikes/:videoId',
 
 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',
@@ -116,7 +116,11 @@ activityPubClientRouter.get('/video-channels/:name/following',
 )
 
 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))
 )
 
@@ -163,7 +167,8 @@ function getAccountVideoRate (rateType: VideoRateType) {
 }
 
 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)
 
index 255026f46f371467c99f93ff5f8368f843978f5a..1f3341bc0c2c21c71f6239f40b44844cb60901a7 100644 (file)
@@ -1,5 +1,5 @@
 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'
@@ -78,6 +78,9 @@ async function getConfig (req: express.Request, res: express.Response) {
       requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
     },
     transcoding: {
+      hls: {
+        enabled: CONFIG.TRANSCODING.HLS.ENABLED
+      },
       enabledResolutions
     },
     import: {
@@ -246,6 +249,9 @@ function customConfig (): CustomConfig {
         '480p': CONFIG.TRANSCODING.RESOLUTIONS[ '480p' ],
         '720p': CONFIG.TRANSCODING.RESOLUTIONS[ '720p' ],
         '1080p': CONFIG.TRANSCODING.RESOLUTIONS[ '1080p' ]
+      },
+      hls: {
+        enabled: CONFIG.TRANSCODING.HLS.ENABLED
       }
     },
     import: {
index 2b2dfa7ca40bbbcf999a5de07dfdc662217c81d5..e04fc81861f38d0015be93dd53577b6c41ac21d1 100644 (file)
@@ -37,6 +37,7 @@ import {
   setDefaultPagination,
   setDefaultSort,
   videosAddValidator,
+  videosCustomGetValidator,
   videosGetValidator,
   videosRemoveValidator,
   videosSortValidator,
@@ -123,9 +124,9 @@ videosRouter.get('/:id/description',
 )
 videosRouter.get('/:id',
   optionalAuthenticate,
-  asyncMiddleware(videosGetValidator),
+  asyncMiddleware(videosCustomGetValidator('only-video-with-rights')),
   asyncMiddleware(checkVideoFollowConstraints),
-  getVideo
+  asyncMiddleware(getVideo)
 )
 videosRouter.post('/:id/views',
   asyncMiddleware(videosGetValidator),
@@ -395,15 +396,17 @@ async function updateVideo (req: express.Request, res: express.Response) {
   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) {
index 4fd58f70c008b11704042f558fce4d42eaf3354a..b21f9da004ea87b63dee0fb2d11af60796f5f0d7 100644 (file)
@@ -1,6 +1,6 @@
 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'
@@ -51,6 +51,13 @@ staticRouter.use(
   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(
index 1deb8c40292f6ae997eb77004b858c1165481e56..8b77d9de77945f2f0582e8eabcf468aa5b49cb41 100644 (file)
@@ -7,6 +7,7 @@ import { Server as WebSocketServer } from 'ws'
 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
 
@@ -21,7 +22,7 @@ const trackerServer = new TrackerServer({
   udp: false,
   ws: false,
   dht: false,
-  filter: function (infoHash, params, cb) {
+  filter: async function (infoHash, params, cb) {
     let ip: string
 
     if (params.type === 'ws') {
@@ -32,19 +33,25 @@ const trackerServer = new TrackerServer({
 
     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)
+    }
   }
 })
 
index f1430055fe93c34a456efc1f220e141a2b3e8df6..eba552524d8bf347eb155b33c1b5d0407c81ef84 100644 (file)
@@ -15,7 +15,7 @@ function activityPubContextify <T> (data: T) {
       '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',
@@ -32,7 +32,8 @@ function activityPubContextify <T> (data: T) {
         waitTranscoding: 'sc:Boolean',
         expires: 'sc:expires',
         support: 'sc:Text',
-        CacheFile: 'pt:CacheFile'
+        CacheFile: 'pt:CacheFile',
+        Infohash: 'pt:Infohash'
       },
       {
         likes: {
index 3fb824e36220832da1b44c42ed383f5a05daea26..f38b82d9744fa99110aaac6863ce6240be63f514 100644 (file)
@@ -193,10 +193,14 @@ function peertubeTruncate (str: string, maxLength: number) {
   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) => {
@@ -262,7 +266,9 @@ export {
   sanitizeHost,
   buildPath,
   peertubeTruncate,
+
   sha256,
+  sha1,
 
   promisify0,
   promisify1,
index e2bd0c55e13013a892905917399f23e19b7e233c..21d5c53ca55f439fe5244ee2f5c55cfc87685137 100644 (file)
@@ -8,9 +8,19 @@ function isCacheFileObjectValid (object: CacheFileObject) {
     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)
+}
index 0f34aab213eb5be8f6057c549310a75d8509895d..ad99c27242f6c718923c34784cab8565f20782a2 100644 (file)
@@ -1,7 +1,7 @@
 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,
@@ -12,7 +12,6 @@ import {
 } 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') &&
@@ -81,6 +80,11 @@ function isRemoteVideoUrlValid (url: any) {
       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)
     )
 }
 
index b6f0ebe6f2478fd06138978efefa9f95995cd4ef..76647fea2be8264c424d887dd6252afc2ebda3ba 100644 (file)
@@ -13,6 +13,10 @@ function isNotEmptyIntArray (value: any) {
   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)
 }
@@ -82,6 +86,7 @@ function isFileValid (
 
 export {
   exists,
+  isArrayOf,
   isNotEmptyIntArray,
   isArray,
   isIdValid,
index 132f4690ec715063fa1abcdd56e0450a553cbe2c..5ad8ed48e67e501942870f2770076371acac1fc3 100644 (file)
@@ -1,5 +1,5 @@
 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'
@@ -29,12 +29,21 @@ function computeResolutionsToTranscode (videoFileHeight: number) {
   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
   }
 }
 
@@ -110,8 +119,10 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
 type TranscodeOptions = {
   inputPath: string
   outputPath: string
-  resolution?: VideoResolution
+  resolution: VideoResolution
   isPortraitMode?: boolean
+
+  generateHlsPlaylist?: boolean
 }
 
 function transcode (options: TranscodeOptions) {
@@ -150,6 +161,16 @@ 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 })
@@ -166,6 +187,7 @@ function transcode (options: TranscodeOptions) {
 // ---------------------------------------------------------------------------
 
 export {
+  getVideoFileSize,
   getVideoFileResolution,
   getDurationFromVideoFile,
   generateImageFromVideoFile,
index 1bd21467dd7352d1e61f97030814962f8f927e13..c90fe06c78e2729174f58f5dee75741d9f194cd5 100644 (file)
@@ -1,10 +1,12 @@
 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)
index 7905d9ffaee5c087fb876077609b7f6d54928b0d..29fdb263ecfd31691e4bab181241df6ecab92fb1 100644 (file)
@@ -12,7 +12,7 @@ function checkMissedConfig () {
     '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',
index 6f3ebb9aa8af82ce81f2d0964110efd06e259af1..98f8f8694cf9a4c6d055633f556f3e00f318afd5 100644 (file)
@@ -16,7 +16,7 @@ let config: IConfig = require('config')
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 325
+const LAST_MIGRATION_VERSION = 330
 
 // ---------------------------------------------------------------------------
 
@@ -192,6 +192,7 @@ const CONFIG = {
     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')),
@@ -259,6 +260,9 @@ const CONFIG = {
       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: {
@@ -590,6 +594,9 @@ const STATIC_PATHS = {
   TORRENTS: '/static/torrents/',
   WEBSEED: '/static/webseed/',
   REDUNDANCY: '/static/redundancy/',
+  PLAYLISTS: {
+    HLS: '/static/playlists/hls'
+  },
   AVATARS: '/static/avatars/',
   VIDEO_CAPTIONS: '/static/video-captions/'
 }
@@ -632,6 +639,9 @@ const CACHE = {
   }
 }
 
+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
 }
@@ -709,6 +719,7 @@ updateWebserverUrls()
 
 export {
   API_VERSION,
+  HLS_REDUNDANCY_DIRECTORY,
   AVATARS_SIZE,
   ACCEPT_HEADERS,
   BCRYPT_SALT_SIZE,
@@ -733,6 +744,7 @@ export {
   PRIVATE_RSA_KEY_SIZE,
   ROUTE_CACHE_LIFETIME,
   SORTABLE_COLUMNS,
+  HLS_PLAYLIST_DIRECTORY,
   FEEDS,
   JOB_TTL,
   NSFW_POLICY_TYPES,
index 84ad2079b94767ec790a54937ca4b03acba86159..fe296142d0354a0f33e993a8f238757fae4d81c7 100644 (file)
@@ -33,6 +33,7 @@ import { AccountBlocklistModel } from '../models/account/account-blocklist'
 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
 
@@ -99,7 +100,8 @@ async function initDatabaseModels (silent: boolean) {
     AccountBlocklistModel,
     ServerBlocklistModel,
     UserNotificationModel,
-    UserNotificationSettingModel
+    UserNotificationSettingModel,
+    VideoStreamingPlaylistModel
   ])
 
   // Check extensions exist in the database
index b9a9da18307e5301e3c66d552052ab4f965343fc..2b22e16fe5f9d50c4986cf6c0ee5f4e372e4db3f 100644 (file)
@@ -6,7 +6,7 @@ import { UserModel } from '../models/account/user'
 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'
 
@@ -73,6 +73,9 @@ function createDirectoriesIfNotExist () {
     tasks.push(ensureDir(dir))
   }
 
+  // Playlist directories
+  tasks.push(ensureDir(HLS_PLAYLIST_DIRECTORY))
+
   return Promise.all(tasks)
 }
 
diff --git a/server/initializers/migrations/0330-video-streaming-playlist.ts b/server/initializers/migrations/0330-video-streaming-playlist.ts
new file mode 100644 (file)
index 0000000..c85a762
--- /dev/null
@@ -0,0 +1,51 @@
+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
+}
index f6f068b456828e37455167d0d881f0b09e31263d..9a40414bba221e7537b6cd7ba4065545d4d57d6d 100644 (file)
@@ -1,11 +1,28 @@
-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
   })
@@ -15,7 +32,7 @@ function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject
   return {
     expiresOn: new Date(cacheFileObject.expires),
     url: cacheFileObject.id,
-    fileUrl: cacheFileObject.url.href,
+    fileUrl: url.href,
     strategy: null,
     videoFileId: videoFile.id,
     actorId: byActor.id
index e3fca0a17adf108f4f93a1f123782dc03c00343e..605aaba066f27f285758ae15bb3983fdd0105c49 100644 (file)
@@ -1,6 +1,6 @@
 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'
@@ -39,17 +39,14 @@ async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel,
   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()
   })
 }
 
index bf1b6e1177fbaccbce7c7c86d3f78cd57a811a3f..8976fcbc8f9f12d892c22263e85c6d99617a960a 100644 (file)
@@ -73,7 +73,8 @@ async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Trans
 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 })
index a68f03edf35df20e22ae678959d90d4f749bc664..839f6647088520906577d7d1e7cb38e5bb4210c3 100644 (file)
@@ -61,7 +61,7 @@ async function sendUpdateActor (accountOrChannel: AccountModel | VideoChannelMod
 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()
index 38f15448cde7f71db014a9d2a4c3a301f7ab3d2d..4229fe094a1cff1d3450330e42658d42ab7a082a 100644 (file)
@@ -5,6 +5,8 @@ import { VideoModel } from '../../models/video/video'
 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
@@ -16,6 +18,10 @@ function getVideoCacheFileActivityPubUrl (videoFile: VideoFileModel) {
   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
 }
@@ -92,6 +98,7 @@ function getUndoActivityPubUrl (originalUrl: string) {
 
 export {
   getVideoActivityPubUrl,
+  getVideoCacheStreamingPlaylistActivityPubUrl,
   getVideoChannelActivityPubUrl,
   getAccountActivityPubUrl,
   getVideoAbuseActivityPubUrl,
index e1e52349939dcdd2d253c24b32eb240a8863ddbb..edd01234fade0d5900c3eb397abcb817577b2b7f 100644 (file)
@@ -2,7 +2,14 @@ import * as Bluebird from 'bluebird'
 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'
@@ -30,6 +37,9 @@ import { AccountModel } from '../../models/account/account'
 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
@@ -263,6 +273,25 @@ async function updateVideoFromAP (options: {
         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)
@@ -367,13 +396,25 @@ export {
 
 // ---------------------------------------------------------------------------
 
-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)
 
@@ -394,8 +435,14 @@ async function createVideo (videoObject: VideoTorrentObject, channelActor: Actor
     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)
 
@@ -473,13 +520,13 @@ async function videoActivityObjectToDBAttributes (
 }
 
 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 => {
@@ -502,7 +549,45 @@ function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: Vid
       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)
   }
 
diff --git a/server/lib/hls.ts b/server/lib/hls.ts
new file mode 100644 (file)
index 0000000..10db6c3
--- /dev/null
@@ -0,0 +1,110 @@
+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
+}
+
+// ---------------------------------------------------------------------------
index 217d666b6dd1b93fca60b3cc5bafe8397e177151..7119ce0cab8a624904bb196387ed9507371e41ba 100644 (file)
@@ -5,17 +5,18 @@ import { VideoModel } from '../../../models/video/video'
 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 = {
@@ -51,21 +52,38 @@ async function processVideoFile (job: Bull.Job) {
     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 => {
@@ -96,9 +114,11 @@ async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) {
     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)
@@ -145,7 +165,7 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: bo
       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 }
   })
@@ -155,6 +175,8 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: bo
     if (isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase)
     if (videoPublished) Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase)
   }
+
+  await createHlsJobIfEnabled(Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution }))
 }
 
 // ---------------------------------------------------------------------------
@@ -163,3 +185,20 @@ export {
   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 })
+  }
+}
index f643ee2268d38969e2403614949cd29a6b39eb42..1a48f2bd041319008be2bd28f10b49d492940a76 100644 (file)
@@ -1,5 +1,5 @@
 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'
@@ -9,9 +9,19 @@ import { join } from 'path'
 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 {
 
@@ -24,28 +34,32 @@ 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 })
       }
     }
 
@@ -63,25 +77,35 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
 
     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)
   }
 
@@ -112,49 +136,93 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
     }
   }
 
-  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) {
@@ -168,8 +236,9 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
     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
 
@@ -177,11 +246,11 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
     }
   }
 
-  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
   }
@@ -191,13 +260,15 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
   }
 
   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) {
index 4460f46e418d5809fbda6ecdf1ce8db112254908..608badfefc90d5040465317169d4337d4a23afb5 100644 (file)
@@ -1,11 +1,14 @@
-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
@@ -17,7 +20,8 @@ async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFi
 
   const transcodeOptions = {
     inputPath: videoInputPath,
-    outputPath: videoTranscodedPath
+    outputPath: videoTranscodedPath,
+    resolution: inputVideoFile.resolution
   }
 
   // Could be very long!
@@ -47,7 +51,7 @@ async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFi
   }
 }
 
-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'
 
@@ -60,13 +64,13 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR
     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)
@@ -84,6 +88,38 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR
   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)
@@ -125,6 +161,7 @@ async function importVideoFile (video: VideoModel, inputFilePath: string) {
 }
 
 export {
+  generateHlsPlaylist,
   optimizeVideofile,
   transcodeOriginalVideofile,
   importVideoFile
index c72ab78b23fa2c253a83956a51ae4e8e6d37b89b..32932250912123e7466911a830b84535cc4983c7 100644 (file)
@@ -13,7 +13,7 @@ import { ActorFollowModel } from '../../models/activitypub/actor-follow'
 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)
@@ -24,7 +24,7 @@ const videoRedundancyGetValidator = [
     .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
@@ -38,7 +38,31 @@ const videoRedundancyGetValidator = [
     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()
@@ -75,6 +99,7 @@ const updateServerRedundancyValidator = [
 // ---------------------------------------------------------------------------
 
 export {
-  videoRedundancyGetValidator,
+  videoFileRedundancyGetValidator,
+  videoPlaylistRedundancyGetValidator,
   updateServerRedundancyValidator
 }
index 8f2ef2d9ac4206ebe1b8ce3905fed6ad4f3bc968..b722bed1482f62bd68b15e7c6cfcd2d7fc483286 100644 (file)
@@ -28,6 +28,7 @@ import { sample } from 'lodash'
 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'
@@ -38,7 +39,17 @@ export enum ScopeNames {
     include: [
       {
         model: () => VideoFileModel,
-        required: true,
+        required: false,
+        include: [
+          {
+            model: () => VideoModel,
+            required: true
+          }
+        ]
+      },
+      {
+        model: () => VideoStreamingPlaylistModel,
+        required: false,
         include: [
           {
             model: () => VideoModel,
@@ -97,12 +108,24 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
 
   @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
@@ -119,13 +142,25 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
   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
   }
@@ -143,6 +178,19 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
     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: {
@@ -191,7 +239,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
     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) {
@@ -333,40 +381,44 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
 
   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)
@@ -403,11 +455,32 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
       }))
   }
 
+  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',
@@ -431,7 +504,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
 
     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` +
       ')'
     )
 
index 1f1b76c1e10e0621dffe5ef18af13c64fa07747f..7d1e371b9f5b310781100862d3182b743e69f945 100644 (file)
@@ -62,7 +62,7 @@ export class VideoFileModel extends Model<VideoFileModel> {
   extname: string
 
   @AllowNull(false)
-  @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
+  @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
   @Column
   infoHash: string
 
@@ -86,14 +86,14 @@ export class VideoFileModel extends Model<VideoFileModel> {
 
   @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,
index de0747f2221229b24d29386ac3b0ff300781d928..e49dbee30651582adc51fda12129332793dbf099 100644 (file)
@@ -1,7 +1,12 @@
 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 {
@@ -11,6 +16,8 @@ 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
@@ -120,7 +127,12 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
     }
   })
 
+  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(),
@@ -133,7 +145,11 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
       id: video.state,
       label: VideoModel.getStateLabel(video.state)
     },
-    files: []
+
+    trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs),
+
+    files: [],
+    streamingPlaylists
   }
 
   // Format and sort video files
@@ -142,6 +158,25 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
   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()
 
@@ -232,6 +267,28 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
     })
   }
 
+  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',
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
new file mode 100644 (file)
index 0000000..bce5377
--- /dev/null
@@ -0,0 +1,154 @@
+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
+  }
+}
index 80a6c78320e20bf5a63f7e71ac51bfc951721ee5..7022607728b105013c04abbe8e40758004d70a7a 100644 (file)
@@ -52,7 +52,7 @@ import {
   ACTIVITY_PUB,
   API_VERSION,
   CONFIG,
-  CONSTRAINTS_FIELDS,
+  CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY,
   PREVIEWS_SIZE,
   REMOTE_SCHEME,
   STATIC_DOWNLOAD_PATHS,
@@ -95,6 +95,7 @@ import * as validator from 'validator'
 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[] = [
@@ -159,7 +160,9 @@ export enum ScopeNames {
   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 = {
@@ -463,6 +466,22 @@ type AvailableForListIDsOptions = {
 
     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: [
       {
@@ -527,22 +546,55 @@ type AvailableForListIDsOptions = {
       }
     ]
   },
-  [ 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: [
@@ -722,6 +774,16 @@ export class VideoModel extends Model<VideoModel> {
   })
   VideoFiles: VideoFileModel[]
 
+  @HasMany(() => VideoStreamingPlaylistModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    hooks: true,
+    onDelete: 'cascade'
+  })
+  VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
+
   @HasMany(() => VideoShareModel, {
     foreignKey: {
       name: 'videoId',
@@ -847,6 +909,9 @@ export class VideoModel extends Model<VideoModel> {
         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
@@ -858,10 +923,6 @@ export class VideoModel extends Model<VideoModel> {
     return undefined
   }
 
-  static list () {
-    return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
-  }
-
   static listLocal () {
     const query = {
       where: {
@@ -869,7 +930,7 @@ export class VideoModel extends Model<VideoModel> {
       }
     }
 
-    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) {
@@ -1200,6 +1261,16 @@ export class VideoModel extends Model<VideoModel> {
     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)
 
@@ -1212,8 +1283,8 @@ export class VideoModel extends Model<VideoModel> {
     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 })
   }
 
@@ -1224,9 +1295,7 @@ export class VideoModel extends Model<VideoModel> {
       }
     }
 
-    return VideoModel
-      .scope([ ScopeNames.WITH_FILES ])
-      .findOne(options)
+    return VideoModel.findOne(options)
   }
 
   static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
@@ -1248,7 +1317,11 @@ export class VideoModel extends Model<VideoModel> {
       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) {
@@ -1263,9 +1336,37 @@ export class VideoModel extends Model<VideoModel> {
     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) {
@@ -1612,6 +1713,14 @@ export class VideoModel extends Model<VideoModel> {
       .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
 
@@ -1646,7 +1755,7 @@ export class VideoModel extends Model<VideoModel> {
 
   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
@@ -1663,6 +1772,10 @@ export class VideoModel extends Model<VideoModel> {
     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()
   }
@@ -1686,4 +1799,8 @@ export class VideoModel extends Model<VideoModel> {
   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)
+  }
 }
index 4038ecbf051e691c0445a312611f8204917740e0..07de2b5a511e83e135959e6d792d716aca74c9c7 100644 (file)
@@ -65,6 +65,9 @@ describe('Test config API validators', function () {
         '480p': true,
         '720p': false,
         '1080p': false
+      },
+      hls: {
+        enabled: false
       }
     },
     import: {
index 9d3ce815339f82e8fe4ad8e37cda9f05ac0eebc3..5b99309fb1ada934e59f2657b80e2e913bf71a1d 100644 (file)
@@ -17,7 +17,7 @@ import {
   viewVideo,
   wait,
   waitUntilLog,
-  checkVideoFilesWereRemoved, removeVideo, getVideoWithToken
+  checkVideoFilesWereRemoved, removeVideo, getVideoWithToken, reRunServer
 } from '../../../../shared/utils'
 import { waitJobs } from '../../../../shared/utils/server/jobs'
 
@@ -48,6 +48,11 @@ function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: numbe
 
 async function runServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) {
   const config = {
+    transcoding: {
+      hls: {
+        enabled: true
+      }
+    },
     redundancy: {
       videos: {
         check_interval: '5 seconds',
@@ -85,7 +90,7 @@ async function runServers (strategy: VideoRedundancyStrategy, additionalParams:
   await waitJobs(servers)
 }
 
-async function check1WebSeed (strategy: VideoRedundancyStrategy, videoUUID?: string) {
+async function check1WebSeed (videoUUID?: string) {
   if (!videoUUID) videoUUID = video1Server2UUID
 
   const webseeds = [
@@ -93,47 +98,17 @@ async function check1WebSeed (strategy: VideoRedundancyStrategy, videoUUID?: str
   ]
 
   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 = [
@@ -158,7 +133,7 @@ async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: st
       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
       })
     }
@@ -174,6 +149,81 @@ async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: st
   }
 }
 
+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)
 
@@ -220,7 +270,8 @@ describe('Test videos redundancy', function () {
     })
 
     it('Should have 1 webseed on the first video', async function () {
-      await check1WebSeed(strategy)
+      await check1WebSeed()
+      await check0PlaylistRedundancies()
       await checkStatsWith1Webseed(strategy)
     })
 
@@ -229,27 +280,29 @@ describe('Test videos redundancy', function () {
     })
 
     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 () {
@@ -267,7 +320,8 @@ describe('Test videos redundancy', function () {
     })
 
     it('Should have 1 webseed on the first video', async function () {
-      await check1WebSeed(strategy)
+      await check1WebSeed()
+      await check0PlaylistRedundancies()
       await checkStatsWith1Webseed(strategy)
     })
 
@@ -276,25 +330,27 @@ describe('Test videos redundancy', function () {
     })
 
     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' ])
     })
@@ -314,7 +370,8 @@ describe('Test videos redundancy', function () {
     })
 
     it('Should have 1 webseed on the first video', async function () {
-      await check1WebSeed(strategy)
+      await check1WebSeed()
+      await check0PlaylistRedundancies()
       await checkStatsWith1Webseed(strategy)
     })
 
@@ -323,18 +380,19 @@ describe('Test videos redundancy', function () {
     })
 
     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)
@@ -344,13 +402,14 @@ describe('Test videos redundancy', function () {
     })
 
     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)
     })
 
@@ -405,7 +464,7 @@ describe('Test videos redundancy', function () {
     })
 
     it('Should still have 2 webseeds after 10 seconds', async function () {
-      this.timeout(40000)
+      this.timeout(80000)
 
       await wait(10000)
 
@@ -420,7 +479,7 @@ describe('Test videos redundancy', function () {
     })
 
     it('Should stop server 1 and expire video redundancy', async function () {
-      this.timeout(40000)
+      this.timeout(80000)
 
       killallServers([ servers[0] ])
 
@@ -446,10 +505,11 @@ describe('Test videos redundancy', function () {
       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' })
@@ -467,8 +527,10 @@ describe('Test videos redundancy', function () {
         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 {
@@ -477,6 +539,26 @@ describe('Test videos redundancy', function () {
       }
     })
 
+    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()
     })
index bebfc739865bb1ca50b54bc2b7b6192a2dec7c89..0dfe6e4fee8cc1d83a5db72ab29291ca16dec7d5 100644 (file)
@@ -57,6 +57,8 @@ function checkInitialConfig (data: CustomConfig) {
   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
 }
@@ -95,6 +97,7 @@ function checkUpdatedConfig (data: CustomConfig) {
   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
@@ -205,6 +208,9 @@ describe('Test config', function () {
           '480p': true,
           '720p': false,
           '1080p': false
+        },
+        hls: {
+          enabled: false
         }
       },
       import: {
index 97f467aae45a4eafb9394b2b6defc01a7ca368e7..a501a80b2e608f3e523b7ce556f2540eb9234046 100644 (file)
@@ -8,6 +8,7 @@ import './video-change-ownership'
 import './video-channels'
 import './video-comments'
 import './video-description'
+import './video-hls'
 import './video-imports'
 import './video-nsfw'
 import './video-privacy'
diff --git a/server/tests/api/videos/video-hls.ts b/server/tests/api/videos/video-hls.ts
new file mode 100644 (file)
index 0000000..71d863b
--- /dev/null
@@ -0,0 +1,145 @@
+/* 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()
+    }
+  })
+})
index 811ea6a9f44f71871cb17fbeb466f535e593144b..d38bb4331d12a4e1a12c3949200168908a3718f7 100644 (file)
@@ -86,6 +86,13 @@ describe('Test update host scripts', function () {
       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)
     }
   })
 
@@ -100,7 +107,7 @@ describe('Test update host scripts', function () {
     }
   })
 
-  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)
 
@@ -112,7 +119,7 @@ describe('Test update host scripts', function () {
     }
   })
 
-  it('Should update torrent hosts', async function () {
+  it('Should have updated torrent hosts', async function () {
     this.timeout(30000)
 
     const res = await getVideosList(server.url)
index 0a5125f5b6fea0bd42dd38cb41e71d1377185363..4b0a3a724dc1d51deaa02a3fc7a755eb579323b6 100644 (file)
@@ -1,9 +1,9 @@
-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
 }
index 118a4f43dafd6728bdcff98916f391c4d1f7bb31..8c89810d686cd8e8f828da7cdd7ca26fa322009f 100644 (file)
@@ -28,25 +28,47 @@ export type ActivityVideoUrlObject = {
   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'
index 7a3eaa33f417a05529c406add51d6d378f04995e..b42ff90c64eb2b8b77ea2351f10aa8c6d21e7ba0 100644 (file)
@@ -61,6 +61,9 @@ export interface CustomConfig {
       '720p': boolean
       '1080p': boolean
     }
+    hls: {
+      enabled: boolean
+    }
   }
 
   import: {
index f4245ed4db308f44351e0d4f647ccaeb67f51b00..baafed31faf8a7a6329399e9cc8b56f02d5d0091 100644 (file)
@@ -25,11 +25,15 @@ export interface ServerConfig {
 
   signup: {
     allowed: boolean,
-    allowedForCurrentIP: boolean,
+    allowedForCurrentIP: boolean
     requiresEmailVerification: boolean
   }
 
   transcoding: {
+    hls: {
+      enabled: boolean
+    }
+
     enabledResolutions: number[]
   }
 
@@ -48,7 +52,7 @@ export interface ServerConfig {
     file: {
       size: {
         max: number
-      },
+      }
       extensions: string[]
     }
   }
diff --git a/shared/models/videos/video-streaming-playlist.model.ts b/shared/models/videos/video-streaming-playlist.model.ts
new file mode 100644 (file)
index 0000000..17f8fe8
--- /dev/null
@@ -0,0 +1,12 @@
+import { VideoStreamingPlaylistType } from './video-streaming-playlist.type'
+
+export class VideoStreamingPlaylist {
+  id: number
+  type: VideoStreamingPlaylistType
+  playlistUrl: string
+  segmentsSha256Url: string
+
+  redundancies: {
+    baseUrl: string
+  }[]
+}
diff --git a/shared/models/videos/video-streaming-playlist.type.ts b/shared/models/videos/video-streaming-playlist.type.ts
new file mode 100644 (file)
index 0000000..3b403f2
--- /dev/null
@@ -0,0 +1,3 @@
+export enum VideoStreamingPlaylistType {
+  HLS = 1
+}
index 022876a0bffe83c66af02018fbc8c2e84378f83c..803db8255253dc0e749a9312e5da1daf15f1ba8c 100644 (file)
@@ -5,6 +5,7 @@ import { VideoChannel } from './channel/video-channel.model'
 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
@@ -86,4 +87,8 @@ export interface VideoDetails extends Video {
   // Not optional in details (unlike in Video)
   waitTranscoding: boolean
   state: VideoConstant<VideoState>
+
+  trackerUrls: string[]
+
+  streamingPlaylists: VideoStreamingPlaylist[]
 }
index e08bbfd2a98955c63e0eeaa8e7f84c9eb7ccc194..156901372f03d2a9a015f2a710f4124bbe68ebd3 100644 (file)
@@ -17,6 +17,8 @@ export * from './users/users'
 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'
index 77e9f61645fd79630afde3533b295e6081fff6e7..fc687c70117e20960df1ff7f58ce7deb4fb7cb21 100644 (file)
@@ -1,10 +1,17 @@
 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,
@@ -13,8 +20,7 @@ function makeGetRequest (options: {
   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)
@@ -164,5 +170,6 @@ export {
   makePostBodyRequest,
   makePutBodyRequest,
   makeDeleteRequest,
+  makeRawRequest,
   updateAvatarRequest
 }
index 0c5512bab5edbabc2aaf7d3ec248908d54620082..29c24cff97f6f18b99e2cea0ac83fdd575951cb7 100644 (file)
@@ -97,6 +97,9 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) {
         '480p': true,
         '720p': false,
         '1080p': false
+      },
+      hls: {
+        enabled: false
       }
     },
     import: {
index cb57e0a69668c04794a374fda53452cb75625352..bde7dd5c286ee22691ac34d5c705d5c96ce2fb0a 100644 (file)
@@ -166,9 +166,13 @@ async function reRunServer (server: ServerInfo, configOverride?: any) {
 }
 
 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
@@ -199,6 +203,7 @@ async function waitUntilLog (server: ServerInfo, str: string, count = 1) {
 // ---------------------------------------------------------------------------
 
 export {
+  checkDirectoryIsEmpty,
   checkTmpIsEmpty,
   ServerInfo,
   flushAndRunMultipleServers,
diff --git a/shared/utils/videos/video-playlists.ts b/shared/utils/videos/video-playlists.ts
new file mode 100644 (file)
index 0000000..9a0710c
--- /dev/null
@@ -0,0 +1,21 @@
+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
+}
index 0cf6e7c4f93d2f04398d929d7cc70ae23a8c2151..b5b33e038fb509910cf3ecc868ccea70ec51a797 100644 (file)
@@ -271,7 +271,16 @@ function removeVideo (url: string, token: string, id: number | string, expectedS
 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
 
@@ -279,7 +288,7 @@ async function checkVideoFilesWereRemoved (
     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) {
index 1e759af1b6a18587d9f12d91368217dc1c77de8c..47c0646e4f0b6472c20fca5a31e7ff583f30a579 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,6 +2,14 @@
 # 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"
@@ -3585,6 +3593,17 @@ hide-powered-by@1.0.0:
   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"
@@ -4851,7 +4870,7 @@ lodash@=3.10.1:
   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==
@@ -6632,6 +6651,11 @@ psl@^1.1.24:
   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"
@@ -6675,7 +6699,7 @@ punycode@^1.4.1:
   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==
@@ -6958,6 +6982,11 @@ reflect-metadata@^0.1.12:
   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"
@@ -7007,6 +7036,23 @@ repeat-string@^1.6.1:
   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"
@@ -7924,6 +7970,11 @@ statuses@~1.4.0:
   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"
@@ -8416,6 +8467,15 @@ touch@^3.1.0:
   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"