Add ability to download a video from direct link or torrent file
authorChocobozzz <florian.bigard@gmail.com>
Thu, 19 Oct 2017 12:58:28 +0000 (14:58 +0200)
committerChocobozzz <florian.bigard@gmail.com>
Thu, 19 Oct 2017 12:58:28 +0000 (14:58 +0200)
14 files changed:
client/src/app/videos/+video-watch/video-download.component.html [new file with mode: 0644]
client/src/app/videos/+video-watch/video-download.component.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/video-magnet.component.html [deleted file]
client/src/app/videos/+video-watch/video-magnet.component.ts [deleted file]
client/src/app/videos/+video-watch/video-watch.component.html
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/app/videos/+video-watch/video-watch.module.ts
client/src/assets/player/peertube-videojs-plugin.ts
server.ts
server/models/video/video-interface.ts
server/models/video/video.ts
server/tests/api/multiple-pods.ts
server/tests/api/single-pod.ts
shared/models/videos/video.model.ts

diff --git a/client/src/app/videos/+video-watch/video-download.component.html b/client/src/app/videos/+video-watch/video-download.component.html
new file mode 100644 (file)
index 0000000..ddc57e9
--- /dev/null
@@ -0,0 +1,30 @@
+<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
+  <div class="modal-dialog">
+    <div class="modal-content modal-lg">
+
+      <div class="modal-header">
+        <button type="button" class="close" aria-label="Close" (click)="hide()">
+          <span aria-hidden="true">&times;</span>
+        </button>
+        <h4 class="modal-title">Download</h4>
+      </div>
+
+      <div class="modal-body">
+        <div *ngFor="let file of video.files" class="resolution-block">
+          <label>{{ file.resolutionLabel }}</label>
+          <a class="btn btn-default " target="_blank" [href]="file.torrentUrl">
+            <span class="glyphicon glyphicon-download"></span>
+            Torrent file
+          </a>
+          <a class="btn btn-default" target="_blank" [href]="file.fileUrl">
+            <span class="glyphicon glyphicon-download"></span>
+            Download
+          </a>
+
+          <!-- Don't display magnet URI for now, this is not compatible with most torrent clients -->
+          <!--<input #magnetUriInput (click)="magnetUriInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="file.magnetUri" />-->
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/client/src/app/videos/+video-watch/video-download.component.ts b/client/src/app/videos/+video-watch/video-download.component.ts
new file mode 100644 (file)
index 0000000..22149aa
--- /dev/null
@@ -0,0 +1,28 @@
+import { Component, Input, ViewChild } from '@angular/core'
+
+import { ModalDirective } from 'ngx-bootstrap/modal'
+
+import { Video } from '../shared'
+
+@Component({
+  selector: 'my-video-download',
+  templateUrl: './video-download.component.html',
+  styles: [ '.resolution-block { margin-top: 20px; }' ]
+})
+export class VideoDownloadComponent {
+  @Input() video: Video = null
+
+  @ViewChild('modal') modal: ModalDirective
+
+  constructor () {
+    // empty
+  }
+
+  show () {
+    this.modal.show()
+  }
+
+  hide () {
+    this.modal.hide()
+  }
+}
diff --git a/client/src/app/videos/+video-watch/video-magnet.component.html b/client/src/app/videos/+video-watch/video-magnet.component.html
deleted file mode 100644 (file)
index 484280c..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
-  <div class="modal-dialog">
-    <div class="modal-content modal-lg">
-
-      <div class="modal-header">
-        <button type="button" class="close" aria-label="Close" (click)="hide()">
-          <span aria-hidden="true">&times;</span>
-        </button>
-        <h4 class="modal-title">Magnet Uri</h4>
-      </div>
-
-      <div class="modal-body">
-        <div *ngFor="let file of video.files">
-          <label>{{ file.resolutionLabel }}</label>
-          <input #magnetUriInput (click)="magnetUriInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="file.magnetUri" />
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
diff --git a/client/src/app/videos/+video-watch/video-magnet.component.ts b/client/src/app/videos/+video-watch/video-magnet.component.ts
deleted file mode 100644 (file)
index f9432e9..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Component, Input, ViewChild } from '@angular/core'
-
-import { ModalDirective } from 'ngx-bootstrap/modal'
-
-import { Video } from '../shared'
-
-@Component({
-  selector: 'my-video-magnet',
-  templateUrl: './video-magnet.component.html'
-})
-export class VideoMagnetComponent {
-  @Input() video: Video = null
-
-  @ViewChild('modal') modal: ModalDirective
-
-  constructor () {
-    // empty
-  }
-
-  show () {
-    this.modal.show()
-  }
-
-  hide () {
-    this.modal.hide()
-  }
-}
index 88863131af5018639ae78f43947ad84ed7c1b26b..5d582734462c0ef9d48cc90b2b6fd4fe83dd18e8 100644 (file)
@@ -71,8 +71,8 @@
           </li>
 
           <li role="menuitem">
-            <a class="dropdown-item" title="Get magnet URI" href="#" (click)="showMagnetUriModal($event)">
-              <span class="glyphicon glyphicon-magnet"></span> Magnet
+            <a class="dropdown-item" title="Download the video" href="#" (click)="showDownloadModal($event)">
+              <span class="glyphicon glyphicon-download-alt"></span> Download
             </a>
           </li>
 
 
 <ng-template [ngIf]="video !== null">
   <my-video-share #videoShareModal [video]="video"></my-video-share>
-  <my-video-magnet #videoMagnetModal [video]="video"></my-video-magnet>
+  <my-video-download #videoDownloadModal [video]="video"></my-video-download>
   <my-video-report #videoReportModal [video]="video"></my-video-report>
 </ng-template>
index bd98e877ca0e468434e514699b7d204425bbea6a..651298c14005d80b12bd8b754bd19d5b4a9b8e9e 100644 (file)
@@ -10,7 +10,7 @@ import { MetaService } from '@ngx-meta/core'
 import { NotificationsService } from 'angular2-notifications'
 
 import { AuthService, ConfirmService } from '../../core'
-import { VideoMagnetComponent } from './video-magnet.component'
+import { VideoDownloadComponent } from './video-download.component'
 import { VideoShareComponent } from './video-share.component'
 import { VideoReportComponent } from './video-report.component'
 import { Video, VideoService } from '../shared'
@@ -23,7 +23,7 @@ import { UserVideoRateType, VideoRateType } from '../../../../../shared'
   styleUrls: [ './video-watch.component.scss' ]
 })
 export class VideoWatchComponent implements OnInit, OnDestroy {
-  @ViewChild('videoMagnetModal') videoMagnetModal: VideoMagnetComponent
+  @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
   @ViewChild('videoShareModal') videoShareModal: VideoShareComponent
   @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
 
@@ -160,9 +160,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     this.videoShareModal.show()
   }
 
-  showMagnetUriModal (event: Event) {
+  showDownloadModal (event: Event) {
     event.preventDefault()
-    this.videoMagnetModal.show()
+    this.videoDownloadModal.show()
   }
 
   isUserLoggedIn () {
index 5f20b171e6df34a9de58ea10c4739c20278c8eed..c6c1344ce27521c7b9e902b5d4b1175cbecf21b2 100644 (file)
@@ -7,7 +7,7 @@ import { SharedModule } from '../../shared'
 import { VideoWatchComponent } from './video-watch.component'
 import { VideoReportComponent } from './video-report.component'
 import { VideoShareComponent } from './video-share.component'
-import { VideoMagnetComponent } from './video-magnet.component'
+import { VideoDownloadComponent } from './video-download.component'
 
 @NgModule({
   imports: [
@@ -18,7 +18,7 @@ import { VideoMagnetComponent } from './video-magnet.component'
   declarations: [
     VideoWatchComponent,
 
-    VideoMagnetComponent,
+    VideoDownloadComponent,
     VideoShareComponent,
     VideoReportComponent
   ],
index 7cf3ea6ccb414f65c3c2a94ae49d8037c5bc011c..19490baf2b805039b7691460ffe99c67a41bf358 100644 (file)
@@ -158,7 +158,12 @@ const peertubePlugin = function (options: PeertubePluginOptions) {
     })
 
     player.torrent.on('error', err => handleError(err))
-    player.torrent.on('warning', err => handleError(err))
+    player.torrent.on('warning', err => {
+      // We don't support HTTP tracker but we don't care -> we use the web socket tracker
+      if (err.message.indexOf('Unsupported tracker protocol: http://') !== -1) return
+
+      return handleError(err)
+    })
 
     player.trigger('videoFileUpdate')
 
index 72bb11e747bc8ccf35e3cf4d2511852af0bb46d5..f50e5bad4b77c9a6c655e129f9a206b3b35123d4 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -79,26 +79,6 @@ app.use(morgan('combined', {
 app.use(bodyParser.json({ limit: '500kb' }))
 app.use(bodyParser.urlencoded({ extended: false }))
 
-// ----------- Views, routes and static files -----------
-
-// API
-const apiRoute = '/api/' + API_VERSION
-app.use(apiRoute, apiRouter)
-
-// Services (oembed...)
-app.use('/services', servicesRouter)
-
-// Client files
-app.use('/', clientsRouter)
-
-// Static files
-app.use('/', staticRouter)
-
-// Always serve index client page (the client is a single page application, let it handle routing)
-app.use('/*', function (req, res, next) {
-  res.sendFile(path.join(__dirname, '../client/dist/index.html'))
-})
-
 // ----------- Tracker -----------
 
 const trackerServer = new TrackerServer({
@@ -122,6 +102,30 @@ wss.on('connection', function (ws) {
   trackerServer.onWebSocketConnection(ws)
 })
 
+const onHttpRequest = trackerServer.onHttpRequest.bind(trackerServer)
+app.get('/tracker/announce', (req, res) => onHttpRequest(req, res, { action: 'announce' }))
+app.get('/tracker/scrape', (req, res) => onHttpRequest(req, res, { action: 'scrape' }))
+
+// ----------- Views, routes and static files -----------
+
+// API
+const apiRoute = '/api/' + API_VERSION
+app.use(apiRoute, apiRouter)
+
+// Services (oembed...)
+app.use('/services', servicesRouter)
+
+// Client files
+app.use('/', clientsRouter)
+
+// Static files
+app.use('/', staticRouter)
+
+// Always serve index client page (the client is a single page application, let it handle routing)
+app.use('/*', function (req, res) {
+  res.sendFile(path.join(__dirname, '../client/dist/index.html'))
+})
+
 // ----------- Errors -----------
 
 // Catch 404 and forward to error handler
index 1402df26a2aaf20f44403b6e16937d1c06f1df36..86ce84dd99335bd8b9d5d0b50d1f3063c855d617 100644 (file)
@@ -18,7 +18,6 @@ export namespace VideoMethods {
   export type ToFormattedJSON = (this: VideoInstance) => FormattedVideo
 
   export type GetOriginalFile = (this: VideoInstance) => VideoFileInstance
-  export type GenerateMagnetUri = (this: VideoInstance, videoFile: VideoFileInstance) => string
   export type GetTorrentFileName = (this: VideoInstance, videoFile: VideoFileInstance) => string
   export type GetVideoFilename = (this: VideoInstance, videoFile: VideoFileInstance) => string
   export type CreatePreview = (this: VideoInstance, videoFile: VideoFileInstance) => Promise<string>
@@ -108,7 +107,6 @@ export interface VideoInstance extends VideoClass, VideoAttributes, Sequelize.In
   createThumbnail: VideoMethods.CreateThumbnail
   createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
   getOriginalFile: VideoMethods.GetOriginalFile
-  generateMagnetUri: VideoMethods.GenerateMagnetUri
   getPreviewName: VideoMethods.GetPreviewName
   getPreviewPath: VideoMethods.GetPreviewPath
   getThumbnailName: VideoMethods.GetThumbnailName
index 4bd8eb98f5221a529c50b3165511c62add0623e0..0b1af4d21efb9963f2382a875986e39b47b955cd 100644 (file)
@@ -52,7 +52,6 @@ import { PREVIEWS_SIZE } from '../../initializers/constants'
 
 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
 let getOriginalFile: VideoMethods.GetOriginalFile
-let generateMagnetUri: VideoMethods.GenerateMagnetUri
 let getVideoFilename: VideoMethods.GetVideoFilename
 let getThumbnailName: VideoMethods.GetThumbnailName
 let getThumbnailPath: VideoMethods.GetThumbnailPath
@@ -254,7 +253,6 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
     createPreview,
     createThumbnail,
     createTorrentAndSetInfoHash,
-    generateMagnetUri,
     getPreviewName,
     getPreviewPath,
     getThumbnailName,
@@ -426,33 +424,6 @@ createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFil
     })
 }
 
-generateMagnetUri = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  let baseUrlHttp
-  let baseUrlWs
-
-  if (this.isOwned()) {
-    baseUrlHttp = CONFIG.WEBSERVER.URL
-    baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
-  } else {
-    baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
-    baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
-  }
-
-  const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
-  const announce = [ baseUrlWs + '/tracker/socket' ]
-  const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
-
-  const magnetHash = {
-    xs,
-    announce,
-    urlList,
-    infoHash: videoFile.infoHash,
-    name: this.name
-  }
-
-  return magnetUtil.encode(magnetHash)
-}
-
 getEmbedPath = function (this: VideoInstance) {
   return '/videos/embed/' + this.uuid
 }
@@ -516,6 +487,7 @@ toFormattedJSON = function (this: VideoInstance) {
   }
 
   // Format and sort video files
+  const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
   json.files = this.VideoFiles
                    .map(videoFile => {
                      let resolutionLabel = videoFile.resolution + 'p'
@@ -523,8 +495,10 @@ toFormattedJSON = function (this: VideoInstance) {
                      const videoFileJson = {
                        resolution: videoFile.resolution,
                        resolutionLabel,
-                       magnetUri: this.generateMagnetUri(videoFile),
-                       size: videoFile.size
+                       magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
+                       size: videoFile.size,
+                       torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
+                       fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
                      }
 
                      return videoFileJson
@@ -972,3 +946,42 @@ function createBaseVideosWhere () {
     }
   }
 }
+
+function getBaseUrls (video: VideoInstance) {
+  let baseUrlHttp
+  let baseUrlWs
+
+  if (video.isOwned()) {
+    baseUrlHttp = CONFIG.WEBSERVER.URL
+    baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
+  } else {
+    baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.Author.Pod.host
+    baseUrlWs = REMOTE_SCHEME.WS + '://' + video.Author.Pod.host
+  }
+
+  return { baseUrlHttp, baseUrlWs }
+}
+
+function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
+  return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
+}
+
+function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
+  return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
+}
+
+function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
+  const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
+  const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
+  const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
+
+  const magnetHash = {
+    xs,
+    announce,
+    urlList,
+    infoHash: videoFile.infoHash,
+    name: video.name
+  }
+
+  return magnetUtil.encode(magnetHash)
+}
index 6c11aace5fc4ec26188d00193232d40fa6d1514f..e0ccb30582c67bf082c3e11244fded56dfe2d273 100644 (file)
@@ -106,6 +106,8 @@ describe('Test multiple pods', function () {
         const file = video.files[0]
         const magnetUri = file.magnetUri
         expect(file.magnetUri).to.have.lengthOf.above(2)
+        expect(file.torrentUrl).to.equal(`http://${video.podHost}/static/torrents/${video.uuid}-${file.resolution}.torrent`)
+        expect(file.fileUrl).to.equal(`http://${video.podHost}/static/webseed/${video.uuid}-${file.resolution}.webm`)
         expect(file.resolution).to.equal(720)
         expect(file.resolutionLabel).to.equal('720p')
         expect(file.size).to.equal(572456)
index 82bc51a3ebabce52f77b6b303f0d817296e0e031..71017b2b30a013f15158218a21b82079ca0bfaf9 100644 (file)
@@ -127,6 +127,8 @@ describe('Test a single pod', function () {
     const file = video.files[0]
     const magnetUri = file.magnetUri
     expect(file.magnetUri).to.have.lengthOf.above(2)
+    expect(file.torrentUrl).to.equal(`${server.url}/static/torrents/${video.uuid}-${file.resolution}.torrent`)
+    expect(file.fileUrl).to.equal(`${server.url}/static/webseed/${video.uuid}-${file.resolution}.webm`)
     expect(file.resolution).to.equal(720)
     expect(file.resolutionLabel).to.equal('720p')
     expect(file.size).to.equal(218910)
index bbcada845412a48b5187410eb86c111afcaab80c..8e47ac06969e28d65e7b31f0fc7a7d0c0dd9f19b 100644 (file)
@@ -3,6 +3,8 @@ export interface VideoFile {
   resolution: number
   resolutionLabel: string
   size: number // Bytes
+  torrentUrl: string
+  fileUrl: string
 }
 
 export interface Video {