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