Server: video.list -> video.listForApi (with pagination, sort...)
[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 modelUtils = require('./utils')
15 const utils = require('../helpers/utils')
16 const webtorrent = require('../lib/webtorrent')
17
18 const http = config.get('webserver.https') === true ? 'https' : 'http'
19 const host = config.get('webserver.host')
20 const port = config.get('webserver.port')
21 const uploadsDir = pathUtils.join(__dirname, '..', '..', config.get('storage.uploads'))
22 const thumbnailsDir = pathUtils.join(__dirname, '..', '..', config.get('storage.thumbnails'))
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: isOwned,
58   toFormatedJSON: toFormatedJSON,
59   toRemoteJSON: toRemoteJSON
60 }
61
62 VideoSchema.statics = {
63   getDurationFromFile: getDurationFromFile,
64   listForApi: listForApi,
65   listByUrlAndMagnet: listByUrlAndMagnet,
66   listByUrls: listByUrls,
67   listOwned: listOwned,
68   listOwnedByAuthor: listOwnedByAuthor,
69   listRemotes: listRemotes,
70   load: load,
71   search: search,
72   seedAllExisting: seedAllExisting
73 }
74
75 VideoSchema.pre('remove', function (next) {
76   const video = this
77   const tasks = []
78
79   tasks.push(
80     function (callback) {
81       removeThumbnail(video, callback)
82     }
83   )
84
85   if (video.isOwned()) {
86     tasks.push(
87       function (callback) {
88         removeFile(video, callback)
89       },
90       function (callback) {
91         removeTorrent(video, callback)
92       }
93     )
94   }
95
96   parallel(tasks, next)
97 })
98
99 VideoSchema.pre('save', function (next) {
100   const video = this
101   const tasks = []
102
103   if (video.isOwned()) {
104     const videoPath = pathUtils.join(uploadsDir, video.filename)
105     this.podUrl = http + '://' + host + ':' + port
106
107     tasks.push(
108       function (callback) {
109         seed(videoPath, callback)
110       },
111       function (callback) {
112         createThumbnail(videoPath, callback)
113       }
114     )
115
116     parallel(tasks, function (err, results) {
117       if (err) return next(err)
118
119       video.magnetUri = results[0].magnetURI
120       video.thumbnail = results[1]
121
122       return next()
123     })
124   } else {
125     generateThumbnailFromBase64(video.thumbnail, function (err, thumbnailName) {
126       if (err) return next(err)
127
128       video.thumbnail = thumbnailName
129
130       return next()
131     })
132   }
133 })
134
135 mongoose.model('Video', VideoSchema)
136
137 // ------------------------------ METHODS ------------------------------
138
139 function isOwned () {
140   return this.filename !== null
141 }
142
143 function toFormatedJSON () {
144   const json = {
145     id: this._id,
146     name: this.name,
147     description: this.description,
148     podUrl: this.podUrl.replace(/^https?:\/\//, ''),
149     isLocal: this.isOwned(),
150     magnetUri: this.magnetUri,
151     author: this.author,
152     duration: this.duration,
153     tags: this.tags,
154     thumbnailPath: constants.THUMBNAILS_STATIC_PATH + '/' + this.thumbnail,
155     createdDate: this.createdDate
156   }
157
158   return json
159 }
160
161 function toRemoteJSON (callback) {
162   const self = this
163
164   // Convert thumbnail to base64
165   fs.readFile(pathUtils.join(thumbnailsDir, this.thumbnail), function (err, thumbnailData) {
166     if (err) {
167       logger.error('Cannot read the thumbnail of the video')
168       return callback(err)
169     }
170
171     const remoteVideo = {
172       name: self.name,
173       description: self.description,
174       magnetUri: self.magnetUri,
175       filename: null,
176       author: self.author,
177       duration: self.duration,
178       thumbnailBase64: new Buffer(thumbnailData).toString('base64'),
179       tags: self.tags,
180       createdDate: self.createdDate,
181       podUrl: self.podUrl
182     }
183
184     return callback(null, remoteVideo)
185   })
186 }
187
188 // ------------------------------ STATICS ------------------------------
189
190 function getDurationFromFile (videoPath, callback) {
191   ffmpeg.ffprobe(videoPath, function (err, metadata) {
192     if (err) return callback(err)
193
194     return callback(null, Math.floor(metadata.format.duration))
195   })
196 }
197
198 function listForApi (start, count, sort, callback) {
199   const query = {}
200   return modelUtils.findWithCount.call(this, query, start, count, sort, callback)
201 }
202
203 function listByUrlAndMagnet (fromUrl, magnetUri, callback) {
204   this.find({ podUrl: fromUrl, magnetUri: magnetUri }, callback)
205 }
206
207 function listByUrls (fromUrls, callback) {
208   this.find({ podUrl: { $in: fromUrls } }, callback)
209 }
210
211 function listOwned (callback) {
212   // If filename is not null this is *our* video
213   this.find({ filename: { $ne: null } }, callback)
214 }
215
216 function listOwnedByAuthor (author, callback) {
217   this.find({ filename: { $ne: null }, author: author }, callback)
218 }
219
220 function listRemotes (callback) {
221   this.find({ filename: null }, callback)
222 }
223
224 function load (id, callback) {
225   this.findById(id, callback)
226 }
227
228 function search (value, field, start, count, sort, callback) {
229   const query = {}
230   // Make an exact search with the magnet
231   if (field === 'magnetUri' || field === 'tags') {
232     query[field] = value
233   } else {
234     query[field] = new RegExp(value)
235   }
236
237   modelUtils.findWithCount.call(this, query, start, count, sort, callback)
238 }
239
240 function seedAllExisting (callback) {
241   listOwned.call(this, function (err, videos) {
242     if (err) return callback(err)
243
244     eachLimit(videos, constants.SEEDS_IN_PARALLEL, function (video, callbackEach) {
245       const videoPath = pathUtils.join(uploadsDir, video.filename)
246       seed(videoPath, callbackEach)
247     }, callback)
248   })
249 }
250
251 // ---------------------------------------------------------------------------
252
253 function removeThumbnail (video, callback) {
254   fs.unlink(thumbnailsDir + video.thumbnail, callback)
255 }
256
257 function removeFile (video, callback) {
258   fs.unlink(uploadsDir + 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   try {
264     webtorrent.remove(video.magnetUri, callback)
265   } catch (err) {
266     logger.warn('Cannot remove the torrent from WebTorrent', { err: err })
267     return callback(null)
268   }
269 }
270
271 function createThumbnail (videoPath, callback) {
272   const filename = pathUtils.basename(videoPath) + '.jpg'
273   ffmpeg(videoPath)
274     .on('error', callback)
275     .on('end', function () {
276       callback(null, filename)
277     })
278     .thumbnail({
279       count: 1,
280       folder: thumbnailsDir,
281       size: constants.THUMBNAILS_SIZE,
282       filename: filename
283     })
284 }
285
286 function seed (path, callback) {
287   logger.info('Seeding %s...', path)
288
289   webtorrent.seed(path, function (torrent) {
290     logger.info('%s seeded (%s).', path, torrent.magnetURI)
291
292     return callback(null, torrent)
293   })
294 }
295
296 function generateThumbnailFromBase64 (data, callback) {
297   // Creating the thumbnail for this remote video
298   utils.generateRandomString(16, function (err, randomString) {
299     if (err) return callback(err)
300
301     const thumbnailName = randomString + '.jpg'
302     fs.writeFile(thumbnailsDir + thumbnailName, data, { encoding: 'base64' }, function (err) {
303       if (err) return callback(err)
304
305       return callback(null, thumbnailName)
306     })
307   })
308 }