diff options
-rw-r--r-- | server/controllers/api/videos.js | 4 | ||||
-rw-r--r-- | server/helpers/custom-validators/videos.js | 8 | ||||
-rw-r--r-- | server/initializers/constants.js | 18 | ||||
-rw-r--r-- | server/models/video.js | 102 |
4 files changed, 92 insertions, 40 deletions
diff --git a/server/controllers/api/videos.js b/server/controllers/api/videos.js index 2c9e4940e..daf452573 100644 --- a/server/controllers/api/videos.js +++ b/server/controllers/api/videos.js | |||
@@ -110,7 +110,7 @@ function addVideo (req, res, next) { | |||
110 | function renameVideoFile (video, callback) { | 110 | function renameVideoFile (video, callback) { |
111 | const videoDir = constants.CONFIG.STORAGE.VIDEOS_DIR | 111 | const videoDir = constants.CONFIG.STORAGE.VIDEOS_DIR |
112 | const source = path.join(videoDir, videoFile.filename) | 112 | const source = path.join(videoDir, videoFile.filename) |
113 | const destination = path.join(videoDir, video.getFilename()) | 113 | const destination = path.join(videoDir, video.getVideoFilename()) |
114 | 114 | ||
115 | fs.rename(source, destination, function (err) { | 115 | fs.rename(source, destination, function (err) { |
116 | return callback(err, video) | 116 | return callback(err, video) |
@@ -118,7 +118,7 @@ function addVideo (req, res, next) { | |||
118 | }, | 118 | }, |
119 | 119 | ||
120 | function insertIntoDB (video, callback) { | 120 | function insertIntoDB (video, callback) { |
121 | video.save(function (err, video, videoFile) { | 121 | video.save(function (err, video) { |
122 | // Assert there are only one argument sent to the next function (video) | 122 | // Assert there are only one argument sent to the next function (video) |
123 | return callback(err, video) | 123 | return callback(err, video) |
124 | }) | 124 | }) |
diff --git a/server/helpers/custom-validators/videos.js b/server/helpers/custom-validators/videos.js index c4c59808f..166158ef3 100644 --- a/server/helpers/custom-validators/videos.js +++ b/server/helpers/custom-validators/videos.js | |||
@@ -13,7 +13,7 @@ const videosValidators = { | |||
13 | isVideoDateValid, | 13 | isVideoDateValid, |
14 | isVideoDescriptionValid, | 14 | isVideoDescriptionValid, |
15 | isVideoDurationValid, | 15 | isVideoDurationValid, |
16 | isVideoMagnetUriValid, | 16 | isVideoMagnetValid, |
17 | isVideoNameValid, | 17 | isVideoNameValid, |
18 | isVideoPodUrlValid, | 18 | isVideoPodUrlValid, |
19 | isVideoTagsValid, | 19 | isVideoTagsValid, |
@@ -31,7 +31,7 @@ function isEachRemoteVideosValid (requests) { | |||
31 | isVideoDateValid(video.createdDate) && | 31 | isVideoDateValid(video.createdDate) && |
32 | isVideoDescriptionValid(video.description) && | 32 | isVideoDescriptionValid(video.description) && |
33 | isVideoDurationValid(video.duration) && | 33 | isVideoDurationValid(video.duration) && |
34 | isVideoMagnetUriValid(video.magnetUri) && | 34 | isVideoMagnetValid(video.magnetUri) && |
35 | isVideoNameValid(video.name) && | 35 | isVideoNameValid(video.name) && |
36 | isVideoPodUrlValid(video.podUrl) && | 36 | isVideoPodUrlValid(video.podUrl) && |
37 | isVideoTagsValid(video.tags) && | 37 | isVideoTagsValid(video.tags) && |
@@ -62,8 +62,8 @@ function isVideoDurationValid (value) { | |||
62 | return validator.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.DURATION) | 62 | return validator.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.DURATION) |
63 | } | 63 | } |
64 | 64 | ||
65 | function isVideoMagnetUriValid (value) { | 65 | function isVideoMagnetValid (value) { |
66 | return validator.isLength(value, VIDEOS_CONSTRAINTS_FIELDS.MAGNET_URI) | 66 | return validator.isLength(value.infoHash, VIDEOS_CONSTRAINTS_FIELDS.MAGNET.XT) |
67 | } | 67 | } |
68 | 68 | ||
69 | function isVideoNameValid (value) { | 69 | function isVideoNameValid (value) { |
diff --git a/server/initializers/constants.js b/server/initializers/constants.js index a50eb2f66..55129fa3e 100644 --- a/server/initializers/constants.js +++ b/server/initializers/constants.js | |||
@@ -66,7 +66,9 @@ const CONSTRAINTS_FIELDS = { | |||
66 | VIDEOS: { | 66 | VIDEOS: { |
67 | NAME: { min: 3, max: 50 }, // Length | 67 | NAME: { min: 3, max: 50 }, // Length |
68 | DESCRIPTION: { min: 3, max: 250 }, // Length | 68 | DESCRIPTION: { min: 3, max: 250 }, // Length |
69 | MAGNET_URI: { min: 10 }, // Length | 69 | MAGNET: { |
70 | XT: { min: 10 } // Length | ||
71 | }, | ||
70 | DURATION: { min: 1, max: 7200 }, // Number | 72 | DURATION: { min: 1, max: 7200 }, // Number |
71 | TAGS: { min: 1, max: 3 }, // Number of total tags | 73 | TAGS: { min: 1, max: 3 }, // Number of total tags |
72 | TAG: { min: 2, max: 10 }, // Length | 74 | TAG: { min: 2, max: 10 }, // Length |
@@ -131,13 +133,18 @@ const REQUEST_ENDPOINTS = { | |||
131 | 133 | ||
132 | // --------------------------------------------------------------------------- | 134 | // --------------------------------------------------------------------------- |
133 | 135 | ||
136 | const REMOTE_SCHEME = { | ||
137 | HTTP: 'https', | ||
138 | WS: 'WS' | ||
139 | } | ||
140 | |||
134 | // Password encryption | 141 | // Password encryption |
135 | const BCRYPT_SALT_SIZE = 10 | 142 | const BCRYPT_SALT_SIZE = 10 |
136 | 143 | ||
137 | // Express static paths (router) | 144 | // Express static paths (router) |
138 | const STATIC_PATHS = { | 145 | const STATIC_PATHS = { |
139 | PREVIEWS: '/static/previews', | 146 | PREVIEWS: '/static/previews/', |
140 | THUMBNAILS: '/static/thumbnails', | 147 | THUMBNAILS: '/static/thumbnails/', |
141 | TORRENTS: '/static/torrents/', | 148 | TORRENTS: '/static/torrents/', |
142 | WEBSEED: '/static/webseed/' | 149 | WEBSEED: '/static/webseed/' |
143 | } | 150 | } |
@@ -161,6 +168,8 @@ if (isTestInstance() === true) { | |||
161 | CONSTRAINTS_FIELDS.VIDEOS.DURATION.max = 14 | 168 | CONSTRAINTS_FIELDS.VIDEOS.DURATION.max = 14 |
162 | FRIEND_SCORE.BASE = 20 | 169 | FRIEND_SCORE.BASE = 20 |
163 | REQUESTS_INTERVAL = 10000 | 170 | REQUESTS_INTERVAL = 10000 |
171 | REMOTE_SCHEME.HTTP = 'http' | ||
172 | REMOTE_SCHEME.WS = 'ws' | ||
164 | STATIC_MAX_AGE = 0 | 173 | STATIC_MAX_AGE = 0 |
165 | } | 174 | } |
166 | 175 | ||
@@ -177,12 +186,13 @@ module.exports = { | |||
177 | OAUTH_LIFETIME, | 186 | OAUTH_LIFETIME, |
178 | PAGINATION_COUNT_DEFAULT, | 187 | PAGINATION_COUNT_DEFAULT, |
179 | PODS_SCORE, | 188 | PODS_SCORE, |
189 | PREVIEWS_SIZE, | ||
190 | REMOTE_SCHEME, | ||
180 | REQUEST_ENDPOINTS, | 191 | REQUEST_ENDPOINTS, |
181 | REQUESTS_IN_PARALLEL, | 192 | REQUESTS_IN_PARALLEL, |
182 | REQUESTS_INTERVAL, | 193 | REQUESTS_INTERVAL, |
183 | REQUESTS_LIMIT, | 194 | REQUESTS_LIMIT, |
184 | RETRY_REQUESTS, | 195 | RETRY_REQUESTS, |
185 | PREVIEWS_SIZE, | ||
186 | SEARCHABLE_COLUMNS, | 196 | SEARCHABLE_COLUMNS, |
187 | SORTABLE_COLUMNS, | 197 | SORTABLE_COLUMNS, |
188 | STATIC_MAX_AGE, | 198 | STATIC_MAX_AGE, |
diff --git a/server/models/video.js b/server/models/video.js index 6cffa87af..19136ba25 100644 --- a/server/models/video.js +++ b/server/models/video.js | |||
@@ -3,10 +3,10 @@ | |||
3 | const createTorrent = require('create-torrent') | 3 | const createTorrent = require('create-torrent') |
4 | const ffmpeg = require('fluent-ffmpeg') | 4 | const ffmpeg = require('fluent-ffmpeg') |
5 | const fs = require('fs') | 5 | const fs = require('fs') |
6 | const magnetUtil = require('magnet-uri') | ||
6 | const parallel = require('async/parallel') | 7 | const parallel = require('async/parallel') |
7 | const parseTorrent = require('parse-torrent') | 8 | const parseTorrent = require('parse-torrent') |
8 | const pathUtils = require('path') | 9 | const pathUtils = require('path') |
9 | const magnet = require('magnet-uri') | ||
10 | const mongoose = require('mongoose') | 10 | const mongoose = require('mongoose') |
11 | 11 | ||
12 | const constants = require('../initializers/constants') | 12 | const constants = require('../initializers/constants') |
@@ -25,7 +25,9 @@ const VideoSchema = mongoose.Schema({ | |||
25 | }, | 25 | }, |
26 | remoteId: mongoose.Schema.Types.ObjectId, | 26 | remoteId: mongoose.Schema.Types.ObjectId, |
27 | description: String, | 27 | description: String, |
28 | magnetUri: String, | 28 | magnet: { |
29 | infoHash: String | ||
30 | }, | ||
29 | podUrl: String, | 31 | podUrl: String, |
30 | author: String, | 32 | author: String, |
31 | duration: Number, | 33 | duration: Number, |
@@ -39,7 +41,6 @@ const VideoSchema = mongoose.Schema({ | |||
39 | 41 | ||
40 | VideoSchema.path('name').validate(customVideosValidators.isVideoNameValid) | 42 | VideoSchema.path('name').validate(customVideosValidators.isVideoNameValid) |
41 | VideoSchema.path('description').validate(customVideosValidators.isVideoDescriptionValid) | 43 | VideoSchema.path('description').validate(customVideosValidators.isVideoDescriptionValid) |
42 | VideoSchema.path('magnetUri').validate(customVideosValidators.isVideoMagnetUriValid) | ||
43 | VideoSchema.path('podUrl').validate(customVideosValidators.isVideoPodUrlValid) | 44 | VideoSchema.path('podUrl').validate(customVideosValidators.isVideoPodUrlValid) |
44 | VideoSchema.path('author').validate(customVideosValidators.isVideoAuthorValid) | 45 | VideoSchema.path('author').validate(customVideosValidators.isVideoAuthorValid) |
45 | VideoSchema.path('duration').validate(customVideosValidators.isVideoDurationValid) | 46 | VideoSchema.path('duration').validate(customVideosValidators.isVideoDurationValid) |
@@ -51,8 +52,10 @@ VideoSchema.path('thumbnail').validate(function (value) { | |||
51 | VideoSchema.path('tags').validate(customVideosValidators.isVideoTagsValid) | 52 | VideoSchema.path('tags').validate(customVideosValidators.isVideoTagsValid) |
52 | 53 | ||
53 | VideoSchema.methods = { | 54 | VideoSchema.methods = { |
54 | getFilename, | 55 | generateMagnetUri, |
55 | getJPEGName, | 56 | getVideoFilename, |
57 | getThumbnailName, | ||
58 | getPreviewName, | ||
56 | getTorrentName, | 59 | getTorrentName, |
57 | isOwned, | 60 | isOwned, |
58 | toFormatedJSON, | 61 | toFormatedJSON, |
@@ -103,8 +106,8 @@ VideoSchema.pre('save', function (next) { | |||
103 | const tasks = [] | 106 | const tasks = [] |
104 | 107 | ||
105 | if (video.isOwned()) { | 108 | if (video.isOwned()) { |
106 | const videoPath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getFilename()) | 109 | const videoPath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename()) |
107 | this.podUrl = constants.CONFIG.WEBSERVER.URL | 110 | this.podUrl = constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT |
108 | 111 | ||
109 | tasks.push( | 112 | tasks.push( |
110 | // TODO: refractoring | 113 | // TODO: refractoring |
@@ -114,7 +117,7 @@ VideoSchema.pre('save', function (next) { | |||
114 | [ constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT + '/tracker/socket' ] | 117 | [ constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT + '/tracker/socket' ] |
115 | ], | 118 | ], |
116 | urlList: [ | 119 | urlList: [ |
117 | constants.CONFIG.WEBSERVER.URL + constants.STATIC_PATHS.WEBSEED + video.getFilename() | 120 | constants.CONFIG.WEBSERVER.URL + constants.STATIC_PATHS.WEBSEED + video.getVideoFilename() |
118 | ] | 121 | ] |
119 | } | 122 | } |
120 | 123 | ||
@@ -125,9 +128,9 @@ VideoSchema.pre('save', function (next) { | |||
125 | if (err) return callback(err) | 128 | if (err) return callback(err) |
126 | 129 | ||
127 | const parsedTorrent = parseTorrent(torrent) | 130 | const parsedTorrent = parseTorrent(torrent) |
128 | parsedTorrent.xs = video.podUrl + constants.STATIC_PATHS.TORRENTS + video.getTorrentName() | 131 | video.magnet.infoHash = parsedTorrent.infoHash |
129 | video.magnetUri = magnet.encode(parsedTorrent) | ||
130 | 132 | ||
133 | console.log(parsedTorrent) | ||
131 | callback(null) | 134 | callback(null) |
132 | }) | 135 | }) |
133 | }) | 136 | }) |
@@ -150,16 +153,57 @@ mongoose.model('Video', VideoSchema) | |||
150 | 153 | ||
151 | // ------------------------------ METHODS ------------------------------ | 154 | // ------------------------------ METHODS ------------------------------ |
152 | 155 | ||
153 | function getFilename () { | 156 | function generateMagnetUri () { |
154 | return this._id + this.extname | 157 | let baseUrlHttp, baseUrlWs |
158 | |||
159 | if (this.isOwned()) { | ||
160 | baseUrlHttp = constants.CONFIG.WEBSERVER.URL | ||
161 | baseUrlWs = constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT | ||
162 | } else { | ||
163 | baseUrlHttp = constants.REMOTE_SCHEME.HTTP + this.podUrl | ||
164 | baseUrlWs = constants.REMOTE_SCHEME.WS + this.podUrl | ||
165 | } | ||
166 | |||
167 | const xs = baseUrlHttp + constants.STATIC_PATHS.TORRENTS + this.getTorrentName() | ||
168 | const announce = baseUrlWs + '/tracker/socket' | ||
169 | const urlList = [ baseUrlHttp + constants.STATIC_PATHS.WEBSEED + this.getVideoFilename() ] | ||
170 | |||
171 | const magnetHash = { | ||
172 | xs, | ||
173 | announce, | ||
174 | urlList, | ||
175 | infoHash: this.magnet.infoHash, | ||
176 | name: this.name | ||
177 | } | ||
178 | |||
179 | return magnetUtil.encode(magnetHash) | ||
155 | } | 180 | } |
156 | 181 | ||
157 | function getJPEGName () { | 182 | function getVideoFilename () { |
183 | if (this.isOwned()) return this._id + this.extname | ||
184 | |||
185 | return this.remoteId + this.extname | ||
186 | } | ||
187 | |||
188 | function getThumbnailName () { | ||
189 | // We always have a copy of the thumbnail | ||
158 | return this._id + '.jpg' | 190 | return this._id + '.jpg' |
159 | } | 191 | } |
160 | 192 | ||
193 | function getPreviewName () { | ||
194 | const extension = '.jpg' | ||
195 | |||
196 | if (this.isOwned()) return this._id + extension | ||
197 | |||
198 | return this.remoteId + extension | ||
199 | } | ||
200 | |||
161 | function getTorrentName () { | 201 | function getTorrentName () { |
162 | return this._id + '.torrent' | 202 | const extension = '.torrent' |
203 | |||
204 | if (this.isOwned()) return this._id + extension | ||
205 | |||
206 | return this.remoteId + extension | ||
163 | } | 207 | } |
164 | 208 | ||
165 | function isOwned () { | 209 | function isOwned () { |
@@ -171,13 +215,13 @@ function toFormatedJSON () { | |||
171 | id: this._id, | 215 | id: this._id, |
172 | name: this.name, | 216 | name: this.name, |
173 | description: this.description, | 217 | description: this.description, |
174 | podUrl: this.podUrl.replace(/^https?:\/\//, ''), | 218 | podUrl: this.podUrl, |
175 | isLocal: this.isOwned(), | 219 | isLocal: this.isOwned(), |
176 | magnetUri: this.magnetUri, | 220 | magnetUri: this.generateMagnetUri(), |
177 | author: this.author, | 221 | author: this.author, |
178 | duration: this.duration, | 222 | duration: this.duration, |
179 | tags: this.tags, | 223 | tags: this.tags, |
180 | thumbnailPath: constants.STATIC_PATHS.THUMBNAILS + '/' + this.getJPEGName(), | 224 | thumbnailPath: constants.STATIC_PATHS.THUMBNAILS + '/' + this.getThumbnailName(), |
181 | createdDate: this.createdDate | 225 | createdDate: this.createdDate |
182 | } | 226 | } |
183 | 227 | ||
@@ -188,7 +232,7 @@ function toRemoteJSON (callback) { | |||
188 | const self = this | 232 | const self = this |
189 | 233 | ||
190 | // Convert thumbnail to base64 | 234 | // Convert thumbnail to base64 |
191 | const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, this.getJPEGName()) | 235 | const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) |
192 | fs.readFile(thumbnailPath, function (err, thumbnailData) { | 236 | fs.readFile(thumbnailPath, function (err, thumbnailData) { |
193 | if (err) { | 237 | if (err) { |
194 | logger.error('Cannot read the thumbnail of the video') | 238 | logger.error('Cannot read the thumbnail of the video') |
@@ -198,7 +242,7 @@ function toRemoteJSON (callback) { | |||
198 | const remoteVideo = { | 242 | const remoteVideo = { |
199 | name: self.name, | 243 | name: self.name, |
200 | description: self.description, | 244 | description: self.description, |
201 | magnetUri: self.magnetUri, | 245 | magnet: self.magnet, |
202 | remoteId: self._id, | 246 | remoteId: self._id, |
203 | author: self.author, | 247 | author: self.author, |
204 | duration: self.duration, | 248 | duration: self.duration, |
@@ -267,11 +311,11 @@ function search (value, field, start, count, sort, callback) { | |||
267 | // --------------------------------------------------------------------------- | 311 | // --------------------------------------------------------------------------- |
268 | 312 | ||
269 | function removeThumbnail (video, callback) { | 313 | function removeThumbnail (video, callback) { |
270 | fs.unlink(constants.CONFIG.STORAGE.THUMBNAILS_DIR + video.getJPEGName(), callback) | 314 | fs.unlink(constants.CONFIG.STORAGE.THUMBNAILS_DIR + video.getThumbnailName(), callback) |
271 | } | 315 | } |
272 | 316 | ||
273 | function removeFile (video, callback) { | 317 | function removeFile (video, callback) { |
274 | fs.unlink(constants.CONFIG.STORAGE.VIDEOS_DIR + video.getFilename(), callback) | 318 | fs.unlink(constants.CONFIG.STORAGE.VIDEOS_DIR + video.getVideoFilename(), callback) |
275 | } | 319 | } |
276 | 320 | ||
277 | function removeTorrent (video, callback) { | 321 | function removeTorrent (video, callback) { |
@@ -280,22 +324,21 @@ function removeTorrent (video, callback) { | |||
280 | 324 | ||
281 | function removePreview (video, callback) { | 325 | function removePreview (video, callback) { |
282 | // Same name than video thumnail | 326 | // Same name than video thumnail |
283 | // TODO: refractoring | 327 | fs.unlink(constants.CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback) |
284 | fs.unlink(constants.CONFIG.STORAGE.PREVIEWS_DIR + video.getJPEGName(), callback) | ||
285 | } | 328 | } |
286 | 329 | ||
287 | function createPreview (video, videoPath, callback) { | 330 | function createPreview (video, videoPath, callback) { |
288 | generateImage(video, videoPath, constants.CONFIG.STORAGE.PREVIEWS_DIR, callback) | 331 | generateImage(video, videoPath, constants.CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback) |
289 | } | 332 | } |
290 | 333 | ||
291 | function createThumbnail (video, videoPath, callback) { | 334 | function createThumbnail (video, videoPath, callback) { |
292 | generateImage(video, videoPath, constants.CONFIG.STORAGE.THUMBNAILS_DIR, constants.THUMBNAILS_SIZE, callback) | 335 | generateImage(video, videoPath, constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), constants.THUMBNAILS_SIZE, callback) |
293 | } | 336 | } |
294 | 337 | ||
295 | function generateThumbnailFromBase64 (video, thumbnailData, callback) { | 338 | function generateThumbnailFromBase64 (video, thumbnailData, callback) { |
296 | // Creating the thumbnail for this remote video) | 339 | // Creating the thumbnail for this remote video) |
297 | 340 | ||
298 | const thumbnailName = video.getJPEGName() | 341 | const thumbnailName = video.getThumbnailName() |
299 | const thumbnailPath = constants.CONFIG.STORAGE.THUMBNAILS_DIR + thumbnailName | 342 | const thumbnailPath = constants.CONFIG.STORAGE.THUMBNAILS_DIR + thumbnailName |
300 | fs.writeFile(thumbnailPath, thumbnailData, { encoding: 'base64' }, function (err) { | 343 | fs.writeFile(thumbnailPath, thumbnailData, { encoding: 'base64' }, function (err) { |
301 | if (err) return callback(err) | 344 | if (err) return callback(err) |
@@ -304,10 +347,9 @@ function generateThumbnailFromBase64 (video, thumbnailData, callback) { | |||
304 | }) | 347 | }) |
305 | } | 348 | } |
306 | 349 | ||
307 | function generateImage (video, videoPath, folder, size, callback) { | 350 | function generateImage (video, videoPath, folder, imageName, size, callback) { |
308 | const filename = video.getJPEGName() | ||
309 | const options = { | 351 | const options = { |
310 | filename, | 352 | filename: imageName, |
311 | count: 1, | 353 | count: 1, |
312 | folder | 354 | folder |
313 | } | 355 | } |
@@ -321,7 +363,7 @@ function generateImage (video, videoPath, folder, size, callback) { | |||
321 | ffmpeg(videoPath) | 363 | ffmpeg(videoPath) |
322 | .on('error', callback) | 364 | .on('error', callback) |
323 | .on('end', function () { | 365 | .on('end', function () { |
324 | callback(null, filename) | 366 | callback(null, imageName) |
325 | }) | 367 | }) |
326 | .thumbnail(options) | 368 | .thumbnail(options) |
327 | } | 369 | } |