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