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