From a96aed15188174c50885dda0df3164a67295e11f Mon Sep 17 00:00:00 2001
From: Chocobozzz <florian.bigard@gmail.com>
Date: Thu, 19 Oct 2017 14:58:28 +0200
Subject: [PATCH] Add ability to download a video from direct link or torrent
 file

---
 .../video-download.component.html             | 30 ++++++++
 ...mponent.ts => video-download.component.ts} |  7 +-
 .../+video-watch/video-magnet.component.html  | 20 -----
 .../+video-watch/video-watch.component.html   |  6 +-
 .../+video-watch/video-watch.component.ts     |  8 +-
 .../videos/+video-watch/video-watch.module.ts |  4 +-
 .../assets/player/peertube-videojs-plugin.ts  |  7 +-
 server.ts                                     | 44 ++++++-----
 server/models/video/video-interface.ts        |  2 -
 server/models/video/video.ts                  | 75 +++++++++++--------
 server/tests/api/multiple-pods.ts             |  2 +
 server/tests/api/single-pod.ts                |  2 +
 shared/models/videos/video.model.ts           |  2 +
 13 files changed, 123 insertions(+), 86 deletions(-)
 create mode 100644 client/src/app/videos/+video-watch/video-download.component.html
 rename client/src/app/videos/+video-watch/{video-magnet.component.ts => video-download.component.ts} (66%)
 delete mode 100644 client/src/app/videos/+video-watch/video-magnet.component.html

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
index 0000000000..ddc57e999b
--- /dev/null
+++ b/client/src/app/videos/+video-watch/video-download.component.html
@@ -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-magnet.component.ts b/client/src/app/videos/+video-watch/video-download.component.ts
similarity index 66%
rename from client/src/app/videos/+video-watch/video-magnet.component.ts
rename to client/src/app/videos/+video-watch/video-download.component.ts
index f9432e92cc..22149aa6be 100644
--- a/client/src/app/videos/+video-watch/video-magnet.component.ts
+++ b/client/src/app/videos/+video-watch/video-download.component.ts
@@ -5,10 +5,11 @@ import { ModalDirective } from 'ngx-bootstrap/modal'
 import { Video } from '../shared'
 
 @Component({
-  selector: 'my-video-magnet',
-  templateUrl: './video-magnet.component.html'
+  selector: 'my-video-download',
+  templateUrl: './video-download.component.html',
+  styles: [ '.resolution-block { margin-top: 20px; }' ]
 })
-export class VideoMagnetComponent {
+export class VideoDownloadComponent {
   @Input() video: Video = null
 
   @ViewChild('modal') modal: ModalDirective
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
index 484280c454..0000000000
--- a/client/src/app/videos/+video-watch/video-magnet.component.html
+++ /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-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html
index 88863131af..5d58273446 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -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>
 
@@ -179,6 +179,6 @@
 
 <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>
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index bd98e877ca..651298c140 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -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 () {
diff --git a/client/src/app/videos/+video-watch/video-watch.module.ts b/client/src/app/videos/+video-watch/video-watch.module.ts
index 5f20b171e6..c6c1344ce2 100644
--- a/client/src/app/videos/+video-watch/video-watch.module.ts
+++ b/client/src/app/videos/+video-watch/video-watch.module.ts
@@ -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
   ],
diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts
index 7cf3ea6ccb..19490baf2b 100644
--- a/client/src/assets/player/peertube-videojs-plugin.ts
+++ b/client/src/assets/player/peertube-videojs-plugin.ts
@@ -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')
 
diff --git a/server.ts b/server.ts
index 72bb11e747..f50e5bad4b 100644
--- 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
diff --git a/server/models/video/video-interface.ts b/server/models/video/video-interface.ts
index 1402df26a2..86ce84dd99 100644
--- a/server/models/video/video-interface.ts
+++ b/server/models/video/video-interface.ts
@@ -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
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 4bd8eb98f5..0b1af4d21e 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -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)
+}
diff --git a/server/tests/api/multiple-pods.ts b/server/tests/api/multiple-pods.ts
index 6c11aace5f..e0ccb30582 100644
--- a/server/tests/api/multiple-pods.ts
+++ b/server/tests/api/multiple-pods.ts
@@ -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)
diff --git a/server/tests/api/single-pod.ts b/server/tests/api/single-pod.ts
index 82bc51a3eb..71017b2b30 100644
--- a/server/tests/api/single-pod.ts
+++ b/server/tests/api/single-pod.ts
@@ -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)
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts
index bbcada8454..8e47ac0696 100644
--- a/shared/models/videos/video.model.ts
+++ b/shared/models/videos/video.model.ts
@@ -3,6 +3,8 @@ export interface VideoFile {
   resolution: number
   resolutionLabel: string
   size: number // Bytes
+  torrentUrl: string
+  fileUrl: string
 }
 
 export interface Video {
-- 
GitLab