]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/video.ts
Continue activitypub
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
CommitLineData
4d4e5cd4 1import * as safeBuffer from 'safe-buffer'
65fcc311 2const Buffer = safeBuffer.Buffer
4d4e5cd4 3import * as magnetUtil from 'magnet-uri'
53abc4c2 4import { map, maxBy, truncate } from 'lodash'
4d4e5cd4 5import * as parseTorrent from 'parse-torrent'
65fcc311 6import { join } from 'path'
e02643f3 7import * as Sequelize from 'sequelize'
65fcc311 8
6fcd19ba 9import { TagInstance } from './tag-interface'
65fcc311
C
10import {
11 logger,
12 isVideoNameValid,
13 isVideoCategoryValid,
14 isVideoLicenceValid,
15 isVideoLanguageValid,
16 isVideoNSFWValid,
17 isVideoDescriptionValid,
6fcd19ba 18 isVideoDurationValid,
fd45e8f4 19 isVideoPrivacyValid,
6fcd19ba
C
20 readFileBufferPromise,
21 unlinkPromise,
22 renamePromise,
23 writeFilePromise,
40298b02 24 createTorrentPromise,
14d3270f
C
25 statPromise,
26 generateImageFromVideoFile,
27 transcode,
0d0e8dd0
C
28 getVideoFileHeight,
29 getActivityPubUrl
74889a71 30} from '../../helpers'
65fcc311 31import {
65fcc311
C
32 CONFIG,
33 REMOTE_SCHEME,
34 STATIC_PATHS,
35 VIDEO_CATEGORIES,
36 VIDEO_LICENCES,
37 VIDEO_LANGUAGES,
9567011b
C
38 THUMBNAILS_SIZE,
39 PREVIEWS_SIZE,
40 CONSTRAINTS_FIELDS,
fd45e8f4
C
41 API_VERSION,
42 VIDEO_PRIVACIES
74889a71 43} from '../../initializers'
93e1258c 44import { removeVideoToFriends } from '../../lib'
fd45e8f4 45import { VideoResolution, VideoPrivacy } from '../../../shared'
40298b02 46import { VideoFileInstance, VideoFileModel } from './video-file-interface'
aaf61f38 47
74889a71 48import { addMethodsToModel, getSort } from '../utils'
e02643f3 49import {
e02643f3
C
50 VideoInstance,
51 VideoAttributes,
52
53 VideoMethods
54} from './video-interface'
e4f97bab 55import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object'
e02643f3
C
56
57let Video: Sequelize.Model<VideoInstance, VideoAttributes>
40298b02 58let getOriginalFile: VideoMethods.GetOriginalFile
e02643f3
C
59let getVideoFilename: VideoMethods.GetVideoFilename
60let getThumbnailName: VideoMethods.GetThumbnailName
d8755eed 61let getThumbnailPath: VideoMethods.GetThumbnailPath
e02643f3 62let getPreviewName: VideoMethods.GetPreviewName
d8755eed 63let getPreviewPath: VideoMethods.GetPreviewPath
93e1258c 64let getTorrentFileName: VideoMethods.GetTorrentFileName
e02643f3 65let isOwned: VideoMethods.IsOwned
0aef76c4 66let toFormattedJSON: VideoMethods.ToFormattedJSON
72c7248b 67let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
e4f97bab 68let toActivityPubObject: VideoMethods.ToActivityPubObject
40298b02
C
69let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
70let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
93e1258c
C
71let createPreview: VideoMethods.CreatePreview
72let createThumbnail: VideoMethods.CreateThumbnail
73let getVideoFilePath: VideoMethods.GetVideoFilePath
74let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
40298b02 75let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
d8755eed 76let getEmbedPath: VideoMethods.GetEmbedPath
9567011b
C
77let getDescriptionPath: VideoMethods.GetDescriptionPath
78let getTruncatedDescription: VideoMethods.GetTruncatedDescription
e4f97bab
C
79let getCategoryLabel: VideoMethods.GetCategoryLabel
80let getLicenceLabel: VideoMethods.GetLicenceLabel
81let getLanguageLabel: VideoMethods.GetLanguageLabel
e02643f3
C
82
83let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
e02643f3
C
84let list: VideoMethods.List
85let listForApi: VideoMethods.ListForApi
fd45e8f4 86let listUserVideosForApi: VideoMethods.ListUserVideosForApi
0a6658fd 87let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
e4f97bab
C
88let listOwnedAndPopulateAccountAndTags: VideoMethods.ListOwnedAndPopulateAccountAndTags
89let listOwnedByAccount: VideoMethods.ListOwnedByAccount
e02643f3 90let load: VideoMethods.Load
0a6658fd 91let loadByUUID: VideoMethods.LoadByUUID
0d0e8dd0 92let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
a041b171 93let loadLocalVideoByUUID: VideoMethods.LoadLocalVideoByUUID
e4f97bab
C
94let loadAndPopulateAccount: VideoMethods.LoadAndPopulateAccount
95let loadAndPopulateAccountAndPodAndTags: VideoMethods.LoadAndPopulateAccountAndPodAndTags
96let loadByUUIDAndPopulateAccountAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndPodAndTags
97let searchAndPopulateAccountAndPodAndTags: VideoMethods.SearchAndPopulateAccountAndPodAndTags
93e1258c
C
98let removeThumbnail: VideoMethods.RemoveThumbnail
99let removePreview: VideoMethods.RemovePreview
100let removeFile: VideoMethods.RemoveFile
101let removeTorrent: VideoMethods.RemoveTorrent
e02643f3 102
127944aa
C
103export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
104 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
feb4bdfd 105 {
0a6658fd 106 uuid: {
feb4bdfd
C
107 type: DataTypes.UUID,
108 defaultValue: DataTypes.UUIDV4,
0a6658fd 109 allowNull: false,
67bf9b96
C
110 validate: {
111 isUUID: 4
112 }
aaf61f38 113 },
feb4bdfd 114 name: {
67bf9b96
C
115 type: DataTypes.STRING,
116 allowNull: false,
117 validate: {
075f16ca 118 nameValid: value => {
65fcc311 119 const res = isVideoNameValid(value)
67bf9b96
C
120 if (res === false) throw new Error('Video name is not valid.')
121 }
122 }
6a94a109 123 },
6e07c3de
C
124 category: {
125 type: DataTypes.INTEGER,
126 allowNull: false,
127 validate: {
075f16ca 128 categoryValid: value => {
65fcc311 129 const res = isVideoCategoryValid(value)
6e07c3de
C
130 if (res === false) throw new Error('Video category is not valid.')
131 }
132 }
133 },
6f0c39e2
C
134 licence: {
135 type: DataTypes.INTEGER,
136 allowNull: false,
3092476e 137 defaultValue: null,
6f0c39e2 138 validate: {
075f16ca 139 licenceValid: value => {
65fcc311 140 const res = isVideoLicenceValid(value)
6f0c39e2
C
141 if (res === false) throw new Error('Video licence is not valid.')
142 }
143 }
144 },
3092476e
C
145 language: {
146 type: DataTypes.INTEGER,
147 allowNull: true,
148 validate: {
075f16ca 149 languageValid: value => {
65fcc311 150 const res = isVideoLanguageValid(value)
3092476e
C
151 if (res === false) throw new Error('Video language is not valid.')
152 }
153 }
154 },
fd45e8f4
C
155 privacy: {
156 type: DataTypes.INTEGER,
157 allowNull: false,
158 validate: {
159 privacyValid: value => {
160 const res = isVideoPrivacyValid(value)
161 if (res === false) throw new Error('Video privacy is not valid.')
162 }
163 }
164 },
31b59b47
C
165 nsfw: {
166 type: DataTypes.BOOLEAN,
167 allowNull: false,
168 validate: {
075f16ca 169 nsfwValid: value => {
65fcc311 170 const res = isVideoNSFWValid(value)
31b59b47
C
171 if (res === false) throw new Error('Video nsfw attribute is not valid.')
172 }
173 }
174 },
feb4bdfd 175 description: {
9567011b 176 type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max),
67bf9b96
C
177 allowNull: false,
178 validate: {
075f16ca 179 descriptionValid: value => {
65fcc311 180 const res = isVideoDescriptionValid(value)
67bf9b96
C
181 if (res === false) throw new Error('Video description is not valid.')
182 }
183 }
feb4bdfd 184 },
feb4bdfd 185 duration: {
67bf9b96
C
186 type: DataTypes.INTEGER,
187 allowNull: false,
188 validate: {
075f16ca 189 durationValid: value => {
65fcc311 190 const res = isVideoDurationValid(value)
67bf9b96
C
191 if (res === false) throw new Error('Video duration is not valid.')
192 }
193 }
9e167724
C
194 },
195 views: {
196 type: DataTypes.INTEGER,
197 allowNull: false,
198 defaultValue: 0,
199 validate: {
200 min: 0,
201 isInt: true
202 }
d38b8281
C
203 },
204 likes: {
205 type: DataTypes.INTEGER,
206 allowNull: false,
207 defaultValue: 0,
208 validate: {
209 min: 0,
210 isInt: true
211 }
212 },
213 dislikes: {
214 type: DataTypes.INTEGER,
215 allowNull: false,
216 defaultValue: 0,
217 validate: {
218 min: 0,
219 isInt: true
220 }
0a6658fd
C
221 },
222 remote: {
223 type: DataTypes.BOOLEAN,
224 allowNull: false,
225 defaultValue: false
e4f97bab
C
226 },
227 url: {
228 type: DataTypes.STRING,
229 allowNull: false,
230 validate: {
231 isUrl: true
232 }
aaf61f38 233 }
feb4bdfd
C
234 },
235 {
319d072e 236 indexes: [
319d072e
C
237 {
238 fields: [ 'name' ]
239 },
240 {
241 fields: [ 'createdAt' ]
242 },
243 {
244 fields: [ 'duration' ]
245 },
9e167724
C
246 {
247 fields: [ 'views' ]
d38b8281
C
248 },
249 {
250 fields: [ 'likes' ]
0a6658fd
C
251 },
252 {
253 fields: [ 'uuid' ]
72c7248b
C
254 },
255 {
256 fields: [ 'channelId' ]
e4f97bab
C
257 },
258 {
259 fields: [ 'parentId' ]
319d072e
C
260 }
261 ],
feb4bdfd 262 hooks: {
feb4bdfd
C
263 afterDestroy
264 }
265 }
266 )
aaf61f38 267
e02643f3
C
268 const classMethods = [
269 associate,
270
271 generateThumbnailFromData,
e02643f3
C
272 list,
273 listForApi,
fd45e8f4 274 listUserVideosForApi,
e4f97bab
C
275 listOwnedAndPopulateAccountAndTags,
276 listOwnedByAccount,
e02643f3 277 load,
e4f97bab
C
278 loadAndPopulateAccount,
279 loadAndPopulateAccountAndPodAndTags,
93e1258c 280 loadByHostAndUUID,
0d0e8dd0 281 loadByUUIDOrURL,
93e1258c 282 loadByUUID,
a041b171 283 loadLocalVideoByUUID,
e4f97bab
C
284 loadByUUIDAndPopulateAccountAndPodAndTags,
285 searchAndPopulateAccountAndPodAndTags
e02643f3
C
286 ]
287 const instanceMethods = [
93e1258c
C
288 createPreview,
289 createThumbnail,
290 createTorrentAndSetInfoHash,
e02643f3 291 getPreviewName,
d8755eed 292 getPreviewPath,
93e1258c 293 getThumbnailName,
d8755eed 294 getThumbnailPath,
93e1258c
C
295 getTorrentFileName,
296 getVideoFilename,
297 getVideoFilePath,
40298b02 298 getOriginalFile,
e02643f3 299 isOwned,
93e1258c
C
300 removeFile,
301 removePreview,
302 removeThumbnail,
303 removeTorrent,
e4f97bab 304 toActivityPubObject,
0aef76c4 305 toFormattedJSON,
72c7248b 306 toFormattedDetailsJSON,
40298b02
C
307 optimizeOriginalVideofile,
308 transcodeOriginalVideofile,
d8755eed 309 getOriginalFileHeight,
9567011b
C
310 getEmbedPath,
311 getTruncatedDescription,
e4f97bab
C
312 getDescriptionPath,
313 getCategoryLabel,
314 getLicenceLabel,
315 getLanguageLabel
e02643f3
C
316 ]
317 addMethodsToModel(Video, classMethods, instanceMethods)
318
feb4bdfd
C
319 return Video
320}
aaf61f38 321
aaf61f38
C
322// ------------------------------ METHODS ------------------------------
323
feb4bdfd 324function associate (models) {
72c7248b 325 Video.belongsTo(models.VideoChannel, {
feb4bdfd 326 foreignKey: {
72c7248b 327 name: 'channelId',
feb4bdfd
C
328 allowNull: false
329 },
330 onDelete: 'cascade'
331 })
7920c273 332
e4f97bab
C
333 Video.belongsTo(models.VideoChannel, {
334 foreignKey: {
335 name: 'parentId',
336 allowNull: true
337 },
338 onDelete: 'cascade'
339 })
340
e02643f3 341 Video.belongsToMany(models.Tag, {
7920c273
C
342 foreignKey: 'videoId',
343 through: models.VideoTag,
344 onDelete: 'cascade'
345 })
55fa55a9 346
e02643f3 347 Video.hasMany(models.VideoAbuse, {
55fa55a9
C
348 foreignKey: {
349 name: 'videoId',
350 allowNull: false
351 },
352 onDelete: 'cascade'
353 })
93e1258c
C
354
355 Video.hasMany(models.VideoFile, {
356 foreignKey: {
357 name: 'videoId',
358 allowNull: false
359 },
360 onDelete: 'cascade'
361 })
feb4bdfd
C
362}
363
911238e3 364function afterDestroy (video: VideoInstance) {
93e1258c 365 const tasks = []
f285faa0 366
93e1258c
C
367 tasks.push(
368 video.removeThumbnail()
369 )
f285faa0 370
93e1258c
C
371 if (video.isOwned()) {
372 const removeVideoToFriendsParams = {
373 uuid: video.uuid
374 }
f285faa0 375
93e1258c
C
376 tasks.push(
377 video.removePreview(),
911238e3 378 removeVideoToFriends(removeVideoToFriendsParams)
93e1258c
C
379 )
380
91f6f169 381 // Remove physical files and torrents
93e1258c 382 video.VideoFiles.forEach(file => {
9fd54056
C
383 tasks.push(video.removeFile(file))
384 tasks.push(video.removeTorrent(file))
93e1258c 385 })
f285faa0
C
386 }
387
93e1258c 388 return Promise.all(tasks)
9fd54056 389 .catch(err => {
6cd44728 390 logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
9fd54056 391 })
558d7c23
C
392}
393
40298b02
C
394getOriginalFile = function (this: VideoInstance) {
395 if (Array.isArray(this.VideoFiles) === false) return undefined
396
14d3270f
C
397 // The original file is the file that have the higher resolution
398 return maxBy(this.VideoFiles, file => file.resolution)
40298b02
C
399}
400
93e1258c 401getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
14d3270f 402 return this.uuid + '-' + videoFile.resolution + videoFile.extname
f285faa0
C
403}
404
70c065d6 405getThumbnailName = function (this: VideoInstance) {
f285faa0 406 // We always have a copy of the thumbnail
0a6658fd
C
407 const extension = '.jpg'
408 return this.uuid + extension
558d7c23
C
409}
410
70c065d6 411getPreviewName = function (this: VideoInstance) {
f285faa0 412 const extension = '.jpg'
0a6658fd 413 return this.uuid + extension
f285faa0
C
414}
415
93e1258c 416getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
f285faa0 417 const extension = '.torrent'
14d3270f 418 return this.uuid + '-' + videoFile.resolution + extension
558d7c23
C
419}
420
70c065d6 421isOwned = function (this: VideoInstance) {
0a6658fd 422 return this.remote === false
aaf61f38
C
423}
424
93e1258c 425createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
164174a6
C
426 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
427
14d3270f
C
428 return generateImageFromVideoFile(
429 this.getVideoFilePath(videoFile),
430 CONFIG.STORAGE.PREVIEWS_DIR,
164174a6
C
431 this.getPreviewName(),
432 imageSize
14d3270f 433 )
93e1258c
C
434}
435
436createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
d8755eed
C
437 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
438
14d3270f
C
439 return generateImageFromVideoFile(
440 this.getVideoFilePath(videoFile),
441 CONFIG.STORAGE.THUMBNAILS_DIR,
442 this.getThumbnailName(),
d8755eed 443 imageSize
14d3270f 444 )
93e1258c
C
445}
446
447getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
448 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
449}
450
e4f97bab 451createTorrentAndSetInfoHash = async function (this: VideoInstance, videoFile: VideoFileInstance) {
93e1258c
C
452 const options = {
453 announceList: [
454 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
455 ],
456 urlList: [
457 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
458 ]
459 }
460
e4f97bab 461 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
fdbda9e3 462
e4f97bab
C
463 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
464 logger.info('Creating torrent %s.', filePath)
93e1258c 465
e4f97bab
C
466 await writeFilePromise(filePath, torrent)
467
468 const parsedTorrent = parseTorrent(torrent)
469 videoFile.infoHash = parsedTorrent.infoHash
93e1258c
C
470}
471
d8755eed
C
472getEmbedPath = function (this: VideoInstance) {
473 return '/videos/embed/' + this.uuid
474}
475
476getThumbnailPath = function (this: VideoInstance) {
477 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
478}
479
480getPreviewPath = function (this: VideoInstance) {
481 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
482}
483
0aef76c4 484toFormattedJSON = function (this: VideoInstance) {
feb4bdfd
C
485 let podHost
486
e4f97bab
C
487 if (this.VideoChannel.Account.Pod) {
488 podHost = this.VideoChannel.Account.Pod.host
feb4bdfd
C
489 } else {
490 // It means it's our video
65fcc311 491 podHost = CONFIG.WEBSERVER.HOST
feb4bdfd
C
492 }
493
aaf61f38 494 const json = {
feb4bdfd 495 id: this.id,
0a6658fd 496 uuid: this.uuid,
aaf61f38 497 name: this.name,
6e07c3de 498 category: this.category,
e4f97bab 499 categoryLabel: this.getCategoryLabel(),
6f0c39e2 500 licence: this.licence,
e4f97bab 501 licenceLabel: this.getLicenceLabel(),
3092476e 502 language: this.language,
e4f97bab 503 languageLabel: this.getLanguageLabel(),
31b59b47 504 nsfw: this.nsfw,
9567011b 505 description: this.getTruncatedDescription(),
feb4bdfd 506 podHost,
aaf61f38 507 isLocal: this.isOwned(),
e4f97bab 508 account: this.VideoChannel.Account.name,
72c7248b
C
509 duration: this.duration,
510 views: this.views,
511 likes: this.likes,
512 dislikes: this.dislikes,
513 tags: map<TagInstance, string>(this.Tags, 'name'),
514 thumbnailPath: this.getThumbnailPath(),
515 previewPath: this.getPreviewPath(),
516 embedPath: this.getEmbedPath(),
517 createdAt: this.createdAt,
518 updatedAt: this.updatedAt
519 }
520
521 return json
522}
523
524toFormattedDetailsJSON = function (this: VideoInstance) {
9567011b 525 const formattedJson = this.toFormattedJSON()
72c7248b 526
fd45e8f4
C
527 // Maybe our pod is not up to date and there are new privacy settings since our version
528 let privacyLabel = VIDEO_PRIVACIES[this.privacy]
529 if (!privacyLabel) privacyLabel = 'Unknown'
530
9567011b 531 const detailsJson = {
fd45e8f4
C
532 privacyLabel,
533 privacy: this.privacy,
9567011b 534 descriptionPath: this.getDescriptionPath(),
72c7248b 535 channel: this.VideoChannel.toFormattedJSON(),
93e1258c 536 files: []
aaf61f38
C
537 }
538
aa8b6df4 539 // Format and sort video files
a96aed15 540 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
9567011b 541 detailsJson.files = this.VideoFiles
aa8b6df4 542 .map(videoFile => {
14d3270f 543 let resolutionLabel = videoFile.resolution + 'p'
aa8b6df4
C
544
545 const videoFileJson = {
546 resolution: videoFile.resolution,
547 resolutionLabel,
a96aed15
C
548 magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
549 size: videoFile.size,
550 torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
551 fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
aa8b6df4
C
552 }
553
554 return videoFileJson
555 })
556 .sort((a, b) => {
557 if (a.resolution < b.resolution) return 1
558 if (a.resolution === b.resolution) return 0
559 return -1
560 })
93e1258c 561
9567011b 562 return Object.assign(formattedJson, detailsJson)
aaf61f38
C
563}
564
e4f97bab
C
565toActivityPubObject = function (this: VideoInstance) {
566 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
aaf61f38 567
e4f97bab
C
568 const tag = this.Tags.map(t => ({
569 type: 'Hashtag',
570 name: t.name
571 }))
572
573 const url = []
574 for (const file of this.VideoFiles) {
575 url.push({
576 type: 'Link',
577 mimeType: 'video/' + file.extname,
578 url: getVideoFileUrl(this, file, baseUrlHttp),
579 width: file.resolution,
580 size: file.size
581 })
aaf61f38 582
e4f97bab
C
583 url.push({
584 type: 'Link',
585 mimeType: 'application/x-bittorrent',
586 url: getTorrentUrl(this, file, baseUrlHttp),
587 width: file.resolution
93e1258c
C
588 })
589
e4f97bab
C
590 url.push({
591 type: 'Link',
592 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
593 url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
594 width: file.resolution
595 })
596 }
aaf61f38 597
e4f97bab
C
598 const videoObject: VideoTorrentObject = {
599 type: 'Video',
0d0e8dd0 600 id: getActivityPubUrl('video', this.uuid),
7b1f49de 601 name: this.name,
e4f97bab
C
602 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
603 duration: 'PT' + this.duration + 'S',
604 uuid: this.uuid,
605 tag,
606 category: {
607 id: this.category,
608 label: this.getCategoryLabel()
609 },
610 licence: {
611 id: this.licence,
612 name: this.getLicenceLabel()
613 },
614 language: {
615 id: this.language,
616 name: this.getLanguageLabel()
617 },
d38b8281 618 views: this.views,
e4f97bab
C
619 nsfw: this.nsfw,
620 published: this.createdAt,
621 updated: this.updatedAt,
622 mediaType: 'text/markdown',
623 content: this.getTruncatedDescription(),
624 icon: {
625 type: 'Image',
626 url: getThumbnailUrl(this, baseUrlHttp),
627 mediaType: 'image/jpeg',
628 width: THUMBNAILS_SIZE.width,
629 height: THUMBNAILS_SIZE.height
630 },
631 url
7b1f49de
C
632 }
633
e4f97bab 634 return videoObject
7b1f49de
C
635}
636
9567011b
C
637getTruncatedDescription = function (this: VideoInstance) {
638 const options = {
639 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
640 }
641
642 return truncate(this.description, options)
643}
644
e4f97bab 645optimizeOriginalVideofile = async function (this: VideoInstance) {
65fcc311 646 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
227d02fe 647 const newExtname = '.mp4'
40298b02 648 const inputVideoFile = this.getOriginalFile()
93e1258c
C
649 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
650 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
227d02fe 651
14d3270f
C
652 const transcodeOptions = {
653 inputPath: videoInputPath,
654 outputPath: videoOutputPath
655 }
656
e4f97bab
C
657 try {
658 // Could be very long!
659 await transcode(transcodeOptions)
14d3270f 660
e4f97bab 661 await unlinkPromise(videoInputPath)
14d3270f 662
e4f97bab
C
663 // Important to do this before getVideoFilename() to take in account the new file extension
664 inputVideoFile.set('extname', newExtname)
665
666 await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
667 const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
668
669 inputVideoFile.set('size', stats.size)
670
671 await this.createTorrentAndSetInfoHash(inputVideoFile)
672 await inputVideoFile.save()
673
674 } catch (err) {
675 // Auto destruction...
676 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
677
678 throw err
679 }
227d02fe
C
680}
681
e4f97bab 682transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
40298b02
C
683 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
684 const extname = '.mp4'
685
686 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
687 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
688
689 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
690 resolution,
691 extname,
692 size: 0,
693 videoId: this.id
694 })
695 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
14d3270f
C
696
697 const transcodeOptions = {
698 inputPath: videoInputPath,
699 outputPath: videoOutputPath,
700 resolution
701 }
14d3270f 702
e4f97bab
C
703 await transcode(transcodeOptions)
704
705 const stats = await statPromise(videoOutputPath)
706
707 newVideoFile.set('size', stats.size)
708
709 await this.createTorrentAndSetInfoHash(newVideoFile)
710
711 await newVideoFile.save()
712
713 this.VideoFiles.push(newVideoFile)
40298b02
C
714}
715
716getOriginalFileHeight = function (this: VideoInstance) {
717 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
718
14d3270f 719 return getVideoFileHeight(originalFilePath)
40298b02
C
720}
721
9567011b
C
722getDescriptionPath = function (this: VideoInstance) {
723 return `/api/${API_VERSION}/videos/${this.uuid}/description`
724}
725
e4f97bab
C
726getCategoryLabel = function (this: VideoInstance) {
727 let categoryLabel = VIDEO_CATEGORIES[this.category]
728
729 // Maybe our pod is not up to date and there are new categories since our version
730 if (!categoryLabel) categoryLabel = 'Misc'
731
732 return categoryLabel
733}
734
735getLicenceLabel = function (this: VideoInstance) {
736 let licenceLabel = VIDEO_LICENCES[this.licence]
0d0e8dd0 737
e4f97bab
C
738 // Maybe our pod is not up to date and there are new licences since our version
739 if (!licenceLabel) licenceLabel = 'Unknown'
740
741 return licenceLabel
742}
743
744getLanguageLabel = function (this: VideoInstance) {
745 // Language is an optional attribute
746 let languageLabel = VIDEO_LANGUAGES[this.language]
747 if (!languageLabel) languageLabel = 'Unknown'
748
749 return languageLabel
750}
751
93e1258c
C
752removeThumbnail = function (this: VideoInstance) {
753 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
754 return unlinkPromise(thumbnailPath)
755}
756
757removePreview = function (this: VideoInstance) {
758 // Same name than video thumbnail
759 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
760}
761
762removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
763 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
764 return unlinkPromise(filePath)
765}
766
767removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
b0f9f39e
C
768 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
769 return unlinkPromise(torrentPath)
93e1258c
C
770}
771
aaf61f38
C
772// ------------------------------ STATICS ------------------------------
773
6fcd19ba 774generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
c77fa067
C
775 // Creating the thumbnail for a remote video
776
777 const thumbnailName = video.getThumbnailName()
65fcc311 778 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
6fcd19ba
C
779 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
780 return thumbnailName
c77fa067
C
781 })
782}
783
6fcd19ba 784list = function () {
93e1258c
C
785 const query = {
786 include: [ Video['sequelize'].models.VideoFile ]
787 }
788
789 return Video.findAll(query)
b769007f
C
790}
791
fd45e8f4
C
792listUserVideosForApi = function (userId: number, start: number, count: number, sort: string) {
793 const query = {
794 distinct: true,
795 offset: start,
796 limit: count,
797 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
798 include: [
799 {
800 model: Video['sequelize'].models.VideoChannel,
801 required: true,
802 include: [
803 {
e4f97bab 804 model: Video['sequelize'].models.Account,
fd45e8f4
C
805 where: {
806 userId
807 },
808 required: true
809 }
810 ]
811 },
812 Video['sequelize'].models.Tag
813 ]
814 }
815
816 return Video.findAndCountAll(query).then(({ rows, count }) => {
817 return {
818 data: rows,
819 total: count
820 }
821 })
822}
823
6fcd19ba 824listForApi = function (start: number, count: number, sort: string) {
feb4bdfd 825 const query = {
e02643f3 826 distinct: true,
feb4bdfd
C
827 offset: start,
828 limit: count,
e02643f3 829 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
feb4bdfd
C
830 include: [
831 {
72c7248b
C
832 model: Video['sequelize'].models.VideoChannel,
833 include: [
834 {
e4f97bab 835 model: Video['sequelize'].models.Account,
72c7248b
C
836 include: [
837 {
838 model: Video['sequelize'].models.Pod,
839 required: false
840 }
841 ]
842 }
843 ]
7920c273 844 },
fd45e8f4 845 Video['sequelize'].models.Tag
198b205c 846 ],
e02643f3 847 where: createBaseVideosWhere()
feb4bdfd
C
848 }
849
6fcd19ba
C
850 return Video.findAndCountAll(query).then(({ rows, count }) => {
851 return {
852 data: rows,
853 total: count
854 }
feb4bdfd 855 })
aaf61f38
C
856}
857
72c7248b
C
858loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Transaction) {
859 const query: Sequelize.FindOptions<VideoAttributes> = {
feb4bdfd 860 where: {
0a6658fd 861 uuid
feb4bdfd
C
862 },
863 include: [
93e1258c
C
864 {
865 model: Video['sequelize'].models.VideoFile
866 },
feb4bdfd 867 {
72c7248b 868 model: Video['sequelize'].models.VideoChannel,
feb4bdfd
C
869 include: [
870 {
e4f97bab 871 model: Video['sequelize'].models.Account,
72c7248b
C
872 include: [
873 {
874 model: Video['sequelize'].models.Pod,
875 required: true,
876 where: {
877 host: fromHost
878 }
879 }
880 ]
feb4bdfd
C
881 }
882 ]
883 }
884 ]
885 }
aaf61f38 886
72c7248b
C
887 if (t !== undefined) query.transaction = t
888
6fcd19ba 889 return Video.findOne(query)
aaf61f38
C
890}
891
e4f97bab 892listOwnedAndPopulateAccountAndTags = function () {
feb4bdfd
C
893 const query = {
894 where: {
0a6658fd 895 remote: false
feb4bdfd 896 },
93e1258c
C
897 include: [
898 Video['sequelize'].models.VideoFile,
72c7248b
C
899 {
900 model: Video['sequelize'].models.VideoChannel,
e4f97bab 901 include: [ Video['sequelize'].models.Account ]
72c7248b 902 },
93e1258c
C
903 Video['sequelize'].models.Tag
904 ]
feb4bdfd
C
905 }
906
6fcd19ba 907 return Video.findAll(query)
aaf61f38
C
908}
909
e4f97bab 910listOwnedByAccount = function (account: string) {
feb4bdfd
C
911 const query = {
912 where: {
0a6658fd 913 remote: false
feb4bdfd
C
914 },
915 include: [
93e1258c
C
916 {
917 model: Video['sequelize'].models.VideoFile
918 },
feb4bdfd 919 {
72c7248b
C
920 model: Video['sequelize'].models.VideoChannel,
921 include: [
922 {
e4f97bab 923 model: Video['sequelize'].models.Account,
72c7248b 924 where: {
e4f97bab 925 name: account
72c7248b
C
926 }
927 }
928 ]
feb4bdfd
C
929 }
930 ]
931 }
9bd26629 932
6fcd19ba 933 return Video.findAll(query)
aaf61f38
C
934}
935
0a6658fd 936load = function (id: number) {
6fcd19ba 937 return Video.findById(id)
feb4bdfd
C
938}
939
72c7248b
C
940loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
941 const query: Sequelize.FindOptions<VideoAttributes> = {
0a6658fd
C
942 where: {
943 uuid
93e1258c
C
944 },
945 include: [ Video['sequelize'].models.VideoFile ]
a041b171
C
946 }
947
948 if (t !== undefined) query.transaction = t
949
950 return Video.findOne(query)
951}
952
0d0e8dd0
C
953loadByUUIDOrURL = function (uuid: string, url: string, t?: Sequelize.Transaction) {
954 const query: Sequelize.FindOptions<VideoAttributes> = {
955 where: {
956 [Sequelize.Op.or]: [
957 { uuid },
958 { url }
959 ]
960 },
961 include: [ Video['sequelize'].models.VideoFile ]
962 }
963
964 if (t !== undefined) query.transaction = t
965
966 return Video.findOne(query)
967}
968
a041b171
C
969loadLocalVideoByUUID = function (uuid: string, t?: Sequelize.Transaction) {
970 const query: Sequelize.FindOptions<VideoAttributes> = {
971 where: {
972 uuid,
973 remote: false
974 },
975 include: [ Video['sequelize'].models.VideoFile ]
0a6658fd 976 }
72c7248b
C
977
978 if (t !== undefined) query.transaction = t
979
0a6658fd
C
980 return Video.findOne(query)
981}
982
e4f97bab 983loadAndPopulateAccount = function (id: number) {
feb4bdfd 984 const options = {
72c7248b
C
985 include: [
986 Video['sequelize'].models.VideoFile,
987 {
988 model: Video['sequelize'].models.VideoChannel,
e4f97bab 989 include: [ Video['sequelize'].models.Account ]
72c7248b
C
990 }
991 ]
feb4bdfd
C
992 }
993
6fcd19ba 994 return Video.findById(id, options)
feb4bdfd
C
995}
996
e4f97bab 997loadAndPopulateAccountAndPodAndTags = function (id: number) {
feb4bdfd
C
998 const options = {
999 include: [
1000 {
72c7248b
C
1001 model: Video['sequelize'].models.VideoChannel,
1002 include: [
1003 {
e4f97bab 1004 model: Video['sequelize'].models.Account,
72c7248b
C
1005 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
1006 }
1007 ]
7920c273 1008 },
93e1258c
C
1009 Video['sequelize'].models.Tag,
1010 Video['sequelize'].models.VideoFile
feb4bdfd
C
1011 ]
1012 }
1013
6fcd19ba 1014 return Video.findById(id, options)
aaf61f38
C
1015}
1016
e4f97bab 1017loadByUUIDAndPopulateAccountAndPodAndTags = function (uuid: string) {
0a6658fd
C
1018 const options = {
1019 where: {
1020 uuid
1021 },
1022 include: [
1023 {
72c7248b
C
1024 model: Video['sequelize'].models.VideoChannel,
1025 include: [
1026 {
e4f97bab 1027 model: Video['sequelize'].models.Account,
72c7248b
C
1028 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
1029 }
1030 ]
0a6658fd 1031 },
93e1258c
C
1032 Video['sequelize'].models.Tag,
1033 Video['sequelize'].models.VideoFile
0a6658fd
C
1034 ]
1035 }
1036
1037 return Video.findOne(options)
1038}
1039
e4f97bab 1040searchAndPopulateAccountAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
e6d4b0ff 1041 const podInclude: Sequelize.IncludeOptions = {
e02643f3 1042 model: Video['sequelize'].models.Pod,
7920c273 1043 required: false
feb4bdfd 1044 }
7920c273 1045
e4f97bab
C
1046 const accountInclude: Sequelize.IncludeOptions = {
1047 model: Video['sequelize'].models.Account,
72c7248b
C
1048 include: [ podInclude ]
1049 }
1050
1051 const videoChannelInclude: Sequelize.IncludeOptions = {
1052 model: Video['sequelize'].models.VideoChannel,
e4f97bab 1053 include: [ accountInclude ],
72c7248b 1054 required: true
feb4bdfd
C
1055 }
1056
e6d4b0ff 1057 const tagInclude: Sequelize.IncludeOptions = {
e02643f3 1058 model: Video['sequelize'].models.Tag
7920c273
C
1059 }
1060
556ddc31 1061 const query: Sequelize.FindOptions<VideoAttributes> = {
e02643f3
C
1062 distinct: true,
1063 where: createBaseVideosWhere(),
feb4bdfd
C
1064 offset: start,
1065 limit: count,
e02643f3 1066 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
feb4bdfd
C
1067 }
1068
fd45e8f4 1069 if (field === 'tags') {
e02643f3 1070 const escapedValue = Video['sequelize'].escape('%' + value + '%')
c2962505 1071 query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
6fcd19ba
C
1072 `(SELECT "VideoTags"."videoId"
1073 FROM "Tags"
1074 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
18c8e945 1075 WHERE name ILIKE ${escapedValue}
6fcd19ba 1076 )`
198b205c 1077 )
7920c273
C
1078 } else if (field === 'host') {
1079 // FIXME: Include our pod? (not stored in the database)
1080 podInclude.where = {
1081 host: {
c2962505 1082 [Sequelize.Op.iLike]: '%' + value + '%'
feb4bdfd 1083 }
feb4bdfd 1084 }
7920c273 1085 podInclude.required = true
e4f97bab
C
1086 } else if (field === 'account') {
1087 accountInclude.where = {
7920c273 1088 name: {
c2962505 1089 [Sequelize.Op.iLike]: '%' + value + '%'
feb4bdfd
C
1090 }
1091 }
aaf61f38 1092 } else {
feb4bdfd 1093 query.where[field] = {
c2962505 1094 [Sequelize.Op.iLike]: '%' + value + '%'
feb4bdfd 1095 }
aaf61f38
C
1096 }
1097
7920c273 1098 query.include = [
fd45e8f4 1099 videoChannelInclude, tagInclude
7920c273
C
1100 ]
1101
6fcd19ba
C
1102 return Video.findAndCountAll(query).then(({ rows, count }) => {
1103 return {
1104 data: rows,
1105 total: count
1106 }
feb4bdfd 1107 })
aaf61f38
C
1108}
1109
aaf61f38
C
1110// ---------------------------------------------------------------------------
1111
15d4ee04
C
1112function createBaseVideosWhere () {
1113 return {
1114 id: {
c2962505 1115 [Sequelize.Op.notIn]: Video['sequelize'].literal(
15d4ee04
C
1116 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
1117 )
fd45e8f4
C
1118 },
1119 privacy: VideoPrivacy.PUBLIC
15d4ee04
C
1120 }
1121}
a96aed15
C
1122
1123function getBaseUrls (video: VideoInstance) {
1124 let baseUrlHttp
1125 let baseUrlWs
1126
1127 if (video.isOwned()) {
1128 baseUrlHttp = CONFIG.WEBSERVER.URL
1129 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1130 } else {
e4f97bab
C
1131 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Pod.host
1132 baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Pod.host
a96aed15
C
1133 }
1134
1135 return { baseUrlHttp, baseUrlWs }
1136}
1137
e4f97bab
C
1138function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
1139 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
1140}
1141
a96aed15
C
1142function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1143 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
1144}
1145
1146function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1147 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
1148}
1149
1150function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
1151 const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
1152 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1153 const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
1154
1155 const magnetHash = {
1156 xs,
1157 announce,
1158 urlList,
1159 infoHash: videoFile.infoHash,
1160 name: video.name
1161 }
1162
1163 return magnetUtil.encode(magnetHash)
1164}