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