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