]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Server shares user videos
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
1 import { map, maxBy, truncate } from 'lodash'
2 import * as magnetUtil from 'magnet-uri'
3 import * as parseTorrent from 'parse-torrent'
4 import { join } from 'path'
5 import * as safeBuffer from 'safe-buffer'
6 import * as Sequelize from 'sequelize'
7 import { VideoPrivacy, VideoResolution } from '../../../shared'
8 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object'
9 import {
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'
29 import {
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
43 import { addMethodsToModel, getSort } from '../utils'
44
45 import { TagInstance } from './tag-interface'
46 import { VideoFileInstance, VideoFileModel } from './video-file-interface'
47 import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface'
48 import { sendDeleteVideo } from '../../lib/activitypub/send-request'
49 import { isVideoUrlValid } from '../../helpers/custom-validators/videos'
50
51 const Buffer = safeBuffer.Buffer
52
53 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
54 let getOriginalFile: VideoMethods.GetOriginalFile
55 let getVideoFilename: VideoMethods.GetVideoFilename
56 let getThumbnailName: VideoMethods.GetThumbnailName
57 let getThumbnailPath: VideoMethods.GetThumbnailPath
58 let getPreviewName: VideoMethods.GetPreviewName
59 let getPreviewPath: VideoMethods.GetPreviewPath
60 let getTorrentFileName: VideoMethods.GetTorrentFileName
61 let isOwned: VideoMethods.IsOwned
62 let toFormattedJSON: VideoMethods.ToFormattedJSON
63 let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
64 let toActivityPubObject: VideoMethods.ToActivityPubObject
65 let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
66 let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
67 let createPreview: VideoMethods.CreatePreview
68 let createThumbnail: VideoMethods.CreateThumbnail
69 let getVideoFilePath: VideoMethods.GetVideoFilePath
70 let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
71 let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
72 let getEmbedPath: VideoMethods.GetEmbedPath
73 let getDescriptionPath: VideoMethods.GetDescriptionPath
74 let getTruncatedDescription: VideoMethods.GetTruncatedDescription
75 let getCategoryLabel: VideoMethods.GetCategoryLabel
76 let getLicenceLabel: VideoMethods.GetLicenceLabel
77 let getLanguageLabel: VideoMethods.GetLanguageLabel
78
79 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
80 let list: VideoMethods.List
81 let listForApi: VideoMethods.ListForApi
82 let listUserVideosForApi: VideoMethods.ListUserVideosForApi
83 let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
84 let listOwnedAndPopulateAccountAndTags: VideoMethods.ListOwnedAndPopulateAccountAndTags
85 let listOwnedByAccount: VideoMethods.ListOwnedByAccount
86 let load: VideoMethods.Load
87 let loadByUUID: VideoMethods.LoadByUUID
88 let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
89 let loadLocalVideoByUUID: VideoMethods.LoadLocalVideoByUUID
90 let loadAndPopulateAccount: VideoMethods.LoadAndPopulateAccount
91 let loadAndPopulateAccountAndServerAndTags: VideoMethods.LoadAndPopulateAccountAndServerAndTags
92 let loadByUUIDAndPopulateAccountAndServerAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndServerAndTags
93 let searchAndPopulateAccountAndServerAndTags: VideoMethods.SearchAndPopulateAccountAndServerAndTags
94 let removeThumbnail: VideoMethods.RemoveThumbnail
95 let removePreview: VideoMethods.RemovePreview
96 let removeFile: VideoMethods.RemoveFile
97 let removeTorrent: VideoMethods.RemoveTorrent
98
99 export 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
320 function 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
352 function 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
378 getOriginalFile = 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
385 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
386 return this.uuid + '-' + videoFile.resolution + videoFile.extname
387 }
388
389 getThumbnailName = function (this: VideoInstance) {
390 // We always have a copy of the thumbnail
391 const extension = '.jpg'
392 return this.uuid + extension
393 }
394
395 getPreviewName = function (this: VideoInstance) {
396 const extension = '.jpg'
397 return this.uuid + extension
398 }
399
400 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
401 const extension = '.torrent'
402 return this.uuid + '-' + videoFile.resolution + extension
403 }
404
405 isOwned = function (this: VideoInstance) {
406 return this.remote === false
407 }
408
409 createPreview = 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
420 createThumbnail = 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
431 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
432 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
433 }
434
435 createTorrentAndSetInfoHash = 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
456 getEmbedPath = function (this: VideoInstance) {
457 return '/videos/embed/' + this.uuid
458 }
459
460 getThumbnailPath = function (this: VideoInstance) {
461 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
462 }
463
464 getPreviewPath = function (this: VideoInstance) {
465 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
466 }
467
468 toFormattedJSON = 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
508 toFormattedDetailsJSON = 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
549 toActivityPubObject = 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.replace('.', ''),
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.toISOString(),
605 updated: this.updatedAt.toISOString(),
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
621 getTruncatedDescription = function (this: VideoInstance) {
622 const options = {
623 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
624 }
625
626 return truncate(this.description, options)
627 }
628
629 optimizeOriginalVideofile = 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
666 transcodeOriginalVideofile = 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
700 getOriginalFileHeight = function (this: VideoInstance) {
701 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
702
703 return getVideoFileHeight(originalFilePath)
704 }
705
706 getDescriptionPath = function (this: VideoInstance) {
707 return `/api/${API_VERSION}/videos/${this.uuid}/description`
708 }
709
710 getCategoryLabel = 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
719 getLicenceLabel = 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
728 getLanguageLabel = 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
736 removeThumbnail = function (this: VideoInstance) {
737 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
738 return unlinkPromise(thumbnailPath)
739 }
740
741 removePreview = function (this: VideoInstance) {
742 // Same name than video thumbnail
743 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
744 }
745
746 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
747 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
748 return unlinkPromise(filePath)
749 }
750
751 removeTorrent = 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
758 generateThumbnailFromData = 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
768 list = function () {
769 const query = {
770 include: [ Video['sequelize'].models.VideoFile ]
771 }
772
773 return Video.findAll(query)
774 }
775
776 listUserVideosForApi = 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
808 listForApi = 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
844 loadByHostAndUUID = 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
878 listOwnedAndPopulateAccountAndTags = 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
896 listOwnedByAccount = 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
922 load = function (id: number) {
923 return Video.findById(id)
924 }
925
926 loadByUUID = 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
939 loadByUUIDOrURL = 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
955 loadLocalVideoByUUID = 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
969 loadAndPopulateAccount = 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
983 loadAndPopulateAccountAndServerAndTags = 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
1003 loadByUUIDAndPopulateAccountAndServerAndTags = 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
1026 searchAndPopulateAccountAndServerAndTags = 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
1098 function 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
1109 function 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
1124 function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
1125 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
1126 }
1127
1128 function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1129 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
1130 }
1131
1132 function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1133 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
1134 }
1135
1136 function 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 }