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