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