Server: update to webseed implementation (tests, lint...)
[oweals/peertube.git] / server / models / video.js
1 'use strict'
2
3 const config = require('config')
4 const createTorrent = require('create-torrent')
5 const ffmpeg = require('fluent-ffmpeg')
6 const fs = require('fs')
7 const parallel = require('async/parallel')
8 const parseTorrent = require('parse-torrent')
9 const pathUtils = require('path')
10 const magnet = require('magnet-uri')
11 const mongoose = require('mongoose')
12
13 const constants = require('../initializers/constants')
14 const customVideosValidators = require('../helpers/custom-validators').videos
15 const logger = require('../helpers/logger')
16 const modelUtils = require('./utils')
17 const utils = require('../helpers/utils')
18
19 const http = config.get('webserver.https') === true ? 'https' : 'http'
20 const host = config.get('webserver.host')
21 const port = config.get('webserver.port')
22 const webseedBaseUrl = http + '://' + host + ':' + port + constants.STATIC_PATHS.WEBSEED
23
24 // ---------------------------------------------------------------------------
25
26 // TODO: add indexes on searchable columns
27 const VideoSchema = mongoose.Schema({
28   name: String,
29   filename: String,
30   description: String,
31   magnetUri: String,
32   podUrl: String,
33   author: String,
34   duration: Number,
35   thumbnail: String,
36   tags: [ String ],
37   createdDate: {
38     type: Date,
39     default: Date.now
40   }
41 })
42
43 VideoSchema.path('name').validate(customVideosValidators.isVideoNameValid)
44 VideoSchema.path('description').validate(customVideosValidators.isVideoDescriptionValid)
45 VideoSchema.path('magnetUri').validate(customVideosValidators.isVideoMagnetUriValid)
46 VideoSchema.path('podUrl').validate(customVideosValidators.isVideoPodUrlValid)
47 VideoSchema.path('author').validate(customVideosValidators.isVideoAuthorValid)
48 VideoSchema.path('duration').validate(customVideosValidators.isVideoDurationValid)
49 // The tumbnail can be the path or the data in base 64
50 // The pre save hook will convert the base 64 data in a file on disk and replace the thumbnail key by the filename
51 VideoSchema.path('thumbnail').validate(function (value) {
52   return customVideosValidators.isVideoThumbnailValid(value) || customVideosValidators.isVideoThumbnail64Valid(value)
53 })
54 VideoSchema.path('tags').validate(customVideosValidators.isVideoTagsValid)
55
56 VideoSchema.methods = {
57   isOwned,
58   toFormatedJSON,
59   toRemoteJSON
60 }
61
62 VideoSchema.statics = {
63   getDurationFromFile,
64   listForApi,
65   listByUrlAndMagnet,
66   listByUrls,
67   listOwned,
68   listOwnedByAuthor,
69   listRemotes,
70   load,
71   search
72 }
73
74 VideoSchema.pre('remove', function (next) {
75   const video = this
76   const tasks = []
77
78   tasks.push(
79     function (callback) {
80       removeThumbnail(video, callback)
81     }
82   )
83
84   if (video.isOwned()) {
85     tasks.push(
86       function (callback) {
87         removeFile(video, callback)
88       },
89       function (callback) {
90         removeTorrent(video, callback)
91       }
92     )
93   }
94
95   parallel(tasks, next)
96 })
97
98 VideoSchema.pre('save', function (next) {
99   const video = this
100   const tasks = []
101
102   if (video.isOwned()) {
103     const videoPath = pathUtils.join(constants.CONFIG.STORAGE.UPLOAD_DIR, video.filename)
104     this.podUrl = constants.CONFIG.WEBSERVER.URL
105
106     tasks.push(
107       // TODO: refractoring
108       function (callback) {
109         createTorrent(videoPath, { announceList: [ [ 'ws://' + host + ':' + port + '/tracker/socket' ] ], urlList: [ webseedBaseUrl + video.filename ] }, function (err, torrent) {
110           if (err) return callback(err)
111
112           fs.writeFile(constants.CONFIG.STORAGE.TORRENTS_DIR + video.filename + '.torrent', torrent, function (err) {
113             if (err) return callback(err)
114
115             const parsedTorrent = parseTorrent(torrent)
116             parsedTorrent.xs = video.podUrl + constants.STATIC_PATHS.TORRENTS + video.filename + '.torrent'
117             video.magnetUri = magnet.encode(parsedTorrent)
118
119             callback(null)
120           })
121         })
122       },
123       function (callback) {
124         createThumbnail(videoPath, callback)
125       }
126     )
127
128     parallel(tasks, function (err, results) {
129       if (err) return next(err)
130
131       video.thumbnail = results[1]
132
133       return next()
134     })
135   } else {
136     generateThumbnailFromBase64(video.thumbnail, function (err, thumbnailName) {
137       if (err) return next(err)
138
139       video.thumbnail = thumbnailName
140
141       return next()
142     })
143   }
144 })
145
146 mongoose.model('Video', VideoSchema)
147
148 // ------------------------------ METHODS ------------------------------
149
150 function isOwned () {
151   return this.filename !== null
152 }
153
154 function toFormatedJSON () {
155   const json = {
156     id: this._id,
157     name: this.name,
158     description: this.description,
159     podUrl: this.podUrl.replace(/^https?:\/\//, ''),
160     isLocal: this.isOwned(),
161     magnetUri: this.magnetUri,
162     author: this.author,
163     duration: this.duration,
164     tags: this.tags,
165     thumbnailPath: constants.STATIC_PATHS.THUMBNAILS + '/' + this.thumbnail,
166     createdDate: this.createdDate
167   }
168
169   return json
170 }
171
172 function toRemoteJSON (callback) {
173   const self = this
174
175   // Convert thumbnail to base64
176   fs.readFile(pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, this.thumbnail), function (err, thumbnailData) {
177     if (err) {
178       logger.error('Cannot read the thumbnail of the video')
179       return callback(err)
180     }
181
182     const remoteVideo = {
183       name: self.name,
184       description: self.description,
185       magnetUri: self.magnetUri,
186       filename: null,
187       author: self.author,
188       duration: self.duration,
189       thumbnailBase64: new Buffer(thumbnailData).toString('base64'),
190       tags: self.tags,
191       createdDate: self.createdDate,
192       podUrl: self.podUrl
193     }
194
195     return callback(null, remoteVideo)
196   })
197 }
198
199 // ------------------------------ STATICS ------------------------------
200
201 function getDurationFromFile (videoPath, callback) {
202   ffmpeg.ffprobe(videoPath, function (err, metadata) {
203     if (err) return callback(err)
204
205     return callback(null, Math.floor(metadata.format.duration))
206   })
207 }
208
209 function listForApi (start, count, sort, callback) {
210   const query = {}
211   return modelUtils.listForApiWithCount.call(this, query, start, count, sort, callback)
212 }
213
214 function listByUrlAndMagnet (fromUrl, magnetUri, callback) {
215   this.find({ podUrl: fromUrl, magnetUri: magnetUri }, callback)
216 }
217
218 function listByUrls (fromUrls, callback) {
219   this.find({ podUrl: { $in: fromUrls } }, callback)
220 }
221
222 function listOwned (callback) {
223   // If filename is not null this is *our* video
224   this.find({ filename: { $ne: null } }, callback)
225 }
226
227 function listOwnedByAuthor (author, callback) {
228   this.find({ filename: { $ne: null }, author: author }, callback)
229 }
230
231 function listRemotes (callback) {
232   this.find({ filename: null }, callback)
233 }
234
235 function load (id, callback) {
236   this.findById(id, callback)
237 }
238
239 function search (value, field, start, count, sort, callback) {
240   const query = {}
241   // Make an exact search with the magnet
242   if (field === 'magnetUri' || field === 'tags') {
243     query[field] = value
244   } else {
245     query[field] = new RegExp(value)
246   }
247
248   modelUtils.listForApiWithCount.call(this, query, start, count, sort, callback)
249 }
250
251 // ---------------------------------------------------------------------------
252
253 function removeThumbnail (video, callback) {
254   fs.unlink(constants.CONFIG.STORAGE.THUMBNAILS_DIR + video.thumbnail, callback)
255 }
256
257 function removeFile (video, callback) {
258   fs.unlink(constants.CONFIG.STORAGE.UPLOAD_DIR + video.filename, callback)
259 }
260
261 // Maybe the torrent is not seeded, but we catch the error to don't stop the removing process
262 function removeTorrent (video, callback) {
263   fs.unlink(constants.CONFIG.STORAGE.TORRENTS_DIR + video.filename + '.torrent', callback)
264 }
265
266 function createThumbnail (videoPath, callback) {
267   const filename = pathUtils.basename(videoPath) + '.jpg'
268   ffmpeg(videoPath)
269     .on('error', callback)
270     .on('end', function () {
271       callback(null, filename)
272     })
273     .thumbnail({
274       count: 1,
275       folder: constants.CONFIG.STORAGE.THUMBNAILS_DIR,
276       size: constants.THUMBNAILS_SIZE,
277       filename: filename
278     })
279 }
280
281 function generateThumbnailFromBase64 (data, callback) {
282   // Creating the thumbnail for this remote video
283   utils.generateRandomString(16, function (err, randomString) {
284     if (err) return callback(err)
285
286     const thumbnailName = randomString + '.jpg'
287     fs.writeFile(constants.CONFIG.STORAGE.THUMBNAILS_DIR + thumbnailName, data, { encoding: 'base64' }, function (err) {
288       if (err) return callback(err)
289
290       return callback(null, thumbnailName)
291     })
292   })
293 }