Implement user API (create, update, remove, list)
[oweals/peertube.git] / server / models / video.js
1 'use strict'
2
3 const config = require('config')
4 const eachLimit = require('async/eachLimit')
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 customVideosValidators = require('../helpers/custom-validators').videos
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(customVideosValidators.isVideoNameValid)
43 VideoSchema.path('description').validate(customVideosValidators.isVideoDescriptionValid)
44 VideoSchema.path('magnetUri').validate(customVideosValidators.isVideoMagnetUriValid)
45 VideoSchema.path('podUrl').validate(customVideosValidators.isVideoPodUrlValid)
46 VideoSchema.path('author').validate(customVideosValidators.isVideoAuthorValid)
47 VideoSchema.path('duration').validate(customVideosValidators.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 customVideosValidators.isVideoThumbnailValid(value) || customVideosValidators.isVideoThumbnail64Valid(value)
52 })
53 VideoSchema.path('tags').validate(customVideosValidators.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   listOwnedByAuthor: listOwnedByAuthor,
68   listRemotes: listRemotes,
69   load: load,
70   search: search,
71   seedAllExisting: seedAllExisting
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(uploadsDir, video.filename)
104     this.podUrl = http + '://' + host + ':' + port
105
106     tasks.push(
107       function (callback) {
108         seed(videoPath, callback)
109       },
110       function (callback) {
111         createThumbnail(videoPath, callback)
112       }
113     )
114
115     parallel(tasks, function (err, results) {
116       if (err) return next(err)
117
118       video.magnetUri = results[0].magnetURI
119       video.thumbnail = results[1]
120
121       return next()
122     })
123   } else {
124     generateThumbnailFromBase64(video.thumbnail, function (err, thumbnailName) {
125       if (err) return next(err)
126
127       video.thumbnail = thumbnailName
128
129       return next()
130     })
131   }
132 })
133
134 mongoose.model('Video', VideoSchema)
135
136 // ------------------------------ METHODS ------------------------------
137
138 function isOwned () {
139   return this.filename !== null
140 }
141
142 function toFormatedJSON () {
143   const json = {
144     id: this._id,
145     name: this.name,
146     description: this.description,
147     podUrl: this.podUrl.replace(/^https?:\/\//, ''),
148     isLocal: this.isOwned(),
149     magnetUri: this.magnetUri,
150     author: this.author,
151     duration: this.duration,
152     tags: this.tags,
153     thumbnailPath: constants.THUMBNAILS_STATIC_PATH + '/' + this.thumbnail,
154     createdDate: this.createdDate
155   }
156
157   return json
158 }
159
160 function toRemoteJSON (callback) {
161   const self = this
162
163   // Convert thumbnail to base64
164   fs.readFile(pathUtils.join(thumbnailsDir, this.thumbnail), function (err, thumbnailData) {
165     if (err) {
166       logger.error('Cannot read the thumbnail of the video')
167       return callback(err)
168     }
169
170     const remoteVideo = {
171       name: self.name,
172       description: self.description,
173       magnetUri: self.magnetUri,
174       filename: null,
175       author: self.author,
176       duration: self.duration,
177       thumbnailBase64: new Buffer(thumbnailData).toString('base64'),
178       tags: self.tags,
179       createdDate: self.createdDate,
180       podUrl: self.podUrl
181     }
182
183     return callback(null, remoteVideo)
184   })
185 }
186
187 // ------------------------------ STATICS ------------------------------
188
189 function getDurationFromFile (videoPath, callback) {
190   ffmpeg.ffprobe(videoPath, function (err, metadata) {
191     if (err) return callback(err)
192
193     return callback(null, Math.floor(metadata.format.duration))
194   })
195 }
196
197 function list (start, count, sort, callback) {
198   const query = {}
199   return findWithCount.call(this, query, start, count, sort, callback)
200 }
201
202 function listByUrlAndMagnet (fromUrl, magnetUri, callback) {
203   this.find({ podUrl: fromUrl, magnetUri: magnetUri }, callback)
204 }
205
206 function listByUrls (fromUrls, callback) {
207   this.find({ podUrl: { $in: fromUrls } }, callback)
208 }
209
210 function listOwned (callback) {
211   // If filename is not null this is *our* video
212   this.find({ filename: { $ne: null } }, callback)
213 }
214
215 function listOwnedByAuthor (author, callback) {
216   this.find({ filename: { $ne: null }, author: author }, callback)
217 }
218
219 function listRemotes (callback) {
220   this.find({ filename: null }, callback)
221 }
222
223 function load (id, callback) {
224   this.findById(id, callback)
225 }
226
227 function search (value, field, start, count, sort, callback) {
228   const query = {}
229   // Make an exact search with the magnet
230   if (field === 'magnetUri' || field === 'tags') {
231     query[field] = value
232   } else {
233     query[field] = new RegExp(value)
234   }
235
236   findWithCount.call(this, query, start, count, sort, callback)
237 }
238
239 function seedAllExisting (callback) {
240   listOwned.call(this, function (err, videos) {
241     if (err) return callback(err)
242
243     eachLimit(videos, constants.SEEDS_IN_PARALLEL, function (video, callbackEach) {
244       const videoPath = pathUtils.join(uploadsDir, video.filename)
245       seed(videoPath, callbackEach)
246     }, callback)
247   })
248 }
249
250 // ---------------------------------------------------------------------------
251
252 function findWithCount (query, start, count, sort, callback) {
253   const self = this
254
255   parallel([
256     function (asyncCallback) {
257       self.find(query).skip(start).limit(count).sort(sort).exec(asyncCallback)
258     },
259     function (asyncCallback) {
260       self.count(query, asyncCallback)
261     }
262   ], function (err, results) {
263     if (err) return callback(err)
264
265     const videos = results[0]
266     const totalVideos = results[1]
267     return callback(null, videos, totalVideos)
268   })
269 }
270
271 function removeThumbnail (video, callback) {
272   fs.unlink(thumbnailsDir + video.thumbnail, callback)
273 }
274
275 function removeFile (video, callback) {
276   fs.unlink(uploadsDir + video.filename, callback)
277 }
278
279 // Maybe the torrent is not seeded, but we catch the error to don't stop the removing process
280 function removeTorrent (video, callback) {
281   try {
282     webtorrent.remove(video.magnetUri, callback)
283   } catch (err) {
284     logger.warn('Cannot remove the torrent from WebTorrent', { err: err })
285     return callback(null)
286   }
287 }
288
289 function createThumbnail (videoPath, callback) {
290   const filename = pathUtils.basename(videoPath) + '.jpg'
291   ffmpeg(videoPath)
292     .on('error', callback)
293     .on('end', function () {
294       callback(null, filename)
295     })
296     .thumbnail({
297       count: 1,
298       folder: thumbnailsDir,
299       size: constants.THUMBNAILS_SIZE,
300       filename: filename
301     })
302 }
303
304 function seed (path, callback) {
305   logger.info('Seeding %s...', path)
306
307   webtorrent.seed(path, function (torrent) {
308     logger.info('%s seeded (%s).', path, torrent.magnetURI)
309
310     return callback(null, torrent)
311   })
312 }
313
314 function generateThumbnailFromBase64 (data, callback) {
315   // Creating the thumbnail for this remote video
316   utils.generateRandomString(16, function (err, randomString) {
317     if (err) return callback(err)
318
319     const thumbnailName = randomString + '.jpg'
320     fs.writeFile(thumbnailsDir + thumbnailName, data, { encoding: 'base64' }, function (err) {
321       if (err) return callback(err)
322
323       return callback(null, thumbnailName)
324     })
325   })
326 }