]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Fix thumbnail update
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
1 import Bluebird from 'bluebird'
2 import { remove } from 'fs-extra'
3 import { maxBy, minBy } from 'lodash'
4 import { join } from 'path'
5 import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
6 import {
7 AllowNull,
8 BeforeDestroy,
9 BelongsTo,
10 BelongsToMany,
11 Column,
12 CreatedAt,
13 DataType,
14 Default,
15 ForeignKey,
16 HasMany,
17 HasOne,
18 Is,
19 IsInt,
20 IsUUID,
21 Min,
22 Model,
23 Scopes,
24 Table,
25 UpdatedAt
26 } from 'sequelize-typescript'
27 import { buildNSFWFilter } from '@server/helpers/express-utils'
28 import { uuidToShort } from '@server/helpers/uuid'
29 import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
30 import { LiveManager } from '@server/lib/live/live-manager'
31 import { removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
32 import { getHLSDirectory, getHLSRedundancyDirectory } from '@server/lib/paths'
33 import { VideoPathManager } from '@server/lib/video-path-manager'
34 import { getServerActor } from '@server/models/application/application'
35 import { ModelCache } from '@server/models/model-cache'
36 import { AttributesOnly, buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
37 import { VideoInclude } from '@shared/models'
38 import { VideoFile } from '@shared/models/videos/video-file.model'
39 import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared'
40 import { VideoObject } from '../../../shared/models/activitypub/objects'
41 import { Video, VideoDetails, VideoRateType, VideoStorage } from '../../../shared/models/videos'
42 import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
43 import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
44 import { peertubeTruncate } from '../../helpers/core-utils'
45 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
46 import { exists, isBooleanValid } from '../../helpers/custom-validators/misc'
47 import {
48 isVideoDescriptionValid,
49 isVideoDurationValid,
50 isVideoNameValid,
51 isVideoPrivacyValid,
52 isVideoStateValid,
53 isVideoSupportValid
54 } from '../../helpers/custom-validators/videos'
55 import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
56 import { logger } from '../../helpers/logger'
57 import { CONFIG } from '../../initializers/config'
58 import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
59 import { sendDeleteVideo } from '../../lib/activitypub/send'
60 import {
61 MChannel,
62 MChannelAccountDefault,
63 MChannelId,
64 MStreamingPlaylist,
65 MStreamingPlaylistFilesVideo,
66 MUserAccountId,
67 MUserId,
68 MVideo,
69 MVideoAccountLight,
70 MVideoAccountLightBlacklistAllFiles,
71 MVideoAP,
72 MVideoDetails,
73 MVideoFileVideo,
74 MVideoFormattable,
75 MVideoFormattableDetails,
76 MVideoForUser,
77 MVideoFullLight,
78 MVideoId,
79 MVideoImmutable,
80 MVideoThumbnail,
81 MVideoThumbnailBlacklist,
82 MVideoWithAllFiles,
83 MVideoWithFile
84 } from '../../types/models'
85 import { MThumbnail } from '../../types/models/video/thumbnail'
86 import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
87 import { VideoAbuseModel } from '../abuse/video-abuse'
88 import { AccountModel } from '../account/account'
89 import { AccountVideoRateModel } from '../account/account-video-rate'
90 import { ActorModel } from '../actor/actor'
91 import { ActorImageModel } from '../actor/actor-image'
92 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
93 import { ServerModel } from '../server/server'
94 import { TrackerModel } from '../server/tracker'
95 import { VideoTrackerModel } from '../server/video-tracker'
96 import { setAsUpdated } from '../shared'
97 import { UserModel } from '../user/user'
98 import { UserVideoHistoryModel } from '../user/user-video-history'
99 import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
100 import {
101 videoFilesModelToFormattedJSON,
102 VideoFormattingJSONOptions,
103 videoModelToActivityPubObject,
104 videoModelToFormattedDetailsJSON,
105 videoModelToFormattedJSON
106 } from './formatter/video-format-utils'
107 import { ScheduleVideoUpdateModel } from './schedule-video-update'
108 import { VideoModelGetQueryBuilder } from './sql/video-model-get-query-builder'
109 import { BuildVideosListQueryOptions, DisplayOnlyForFollowerOptions, VideosIdListQueryBuilder } from './sql/videos-id-list-query-builder'
110 import { VideosModelListQueryBuilder } from './sql/videos-model-list-query-builder'
111 import { TagModel } from './tag'
112 import { ThumbnailModel } from './thumbnail'
113 import { VideoBlacklistModel } from './video-blacklist'
114 import { VideoCaptionModel } from './video-caption'
115 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
116 import { VideoCommentModel } from './video-comment'
117 import { VideoFileModel } from './video-file'
118 import { VideoImportModel } from './video-import'
119 import { VideoJobInfoModel } from './video-job-info'
120 import { VideoLiveModel } from './video-live'
121 import { VideoPlaylistElementModel } from './video-playlist-element'
122 import { VideoShareModel } from './video-share'
123 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
124 import { VideoTagModel } from './video-tag'
125 import { VideoViewModel } from './video-view'
126
127 export enum ScopeNames {
128 FOR_API = 'FOR_API',
129 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
130 WITH_TAGS = 'WITH_TAGS',
131 WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES',
132 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
133 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
134 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
135 WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES',
136 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
137 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
138 }
139
140 export type ForAPIOptions = {
141 ids?: number[]
142
143 videoPlaylistId?: number
144
145 withAccountBlockerIds?: number[]
146 }
147
148 @Scopes(() => ({
149 [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: {
150 attributes: [ 'id', 'url', 'uuid', 'remote' ]
151 },
152 [ScopeNames.FOR_API]: (options: ForAPIOptions) => {
153 const include: Includeable[] = [
154 {
155 model: VideoChannelModel.scope({
156 method: [
157 VideoChannelScopeNames.SUMMARY, {
158 withAccount: true,
159 withAccountBlockerIds: options.withAccountBlockerIds
160 } as SummaryOptions
161 ]
162 }),
163 required: true
164 },
165 {
166 attributes: [ 'type', 'filename' ],
167 model: ThumbnailModel,
168 required: false
169 }
170 ]
171
172 const query: FindOptions = {}
173
174 if (options.ids) {
175 query.where = {
176 id: {
177 [Op.in]: options.ids
178 }
179 }
180 }
181
182 if (options.videoPlaylistId) {
183 include.push({
184 model: VideoPlaylistElementModel.unscoped(),
185 required: true,
186 where: {
187 videoPlaylistId: options.videoPlaylistId
188 }
189 })
190 }
191
192 query.include = include
193
194 return query
195 },
196 [ScopeNames.WITH_THUMBNAILS]: {
197 include: [
198 {
199 model: ThumbnailModel,
200 required: false
201 }
202 ]
203 },
204 [ScopeNames.WITH_ACCOUNT_DETAILS]: {
205 include: [
206 {
207 model: VideoChannelModel.unscoped(),
208 required: true,
209 include: [
210 {
211 attributes: {
212 exclude: [ 'privateKey', 'publicKey' ]
213 },
214 model: ActorModel.unscoped(),
215 required: true,
216 include: [
217 {
218 attributes: [ 'host' ],
219 model: ServerModel.unscoped(),
220 required: false
221 },
222 {
223 model: ActorImageModel.unscoped(),
224 as: 'Avatar',
225 required: false
226 }
227 ]
228 },
229 {
230 model: AccountModel.unscoped(),
231 required: true,
232 include: [
233 {
234 model: ActorModel.unscoped(),
235 attributes: {
236 exclude: [ 'privateKey', 'publicKey' ]
237 },
238 required: true,
239 include: [
240 {
241 attributes: [ 'host' ],
242 model: ServerModel.unscoped(),
243 required: false
244 },
245 {
246 model: ActorImageModel.unscoped(),
247 as: 'Avatar',
248 required: false
249 }
250 ]
251 }
252 ]
253 }
254 ]
255 }
256 ]
257 },
258 [ScopeNames.WITH_TAGS]: {
259 include: [ TagModel ]
260 },
261 [ScopeNames.WITH_BLACKLISTED]: {
262 include: [
263 {
264 attributes: [ 'id', 'reason', 'unfederated' ],
265 model: VideoBlacklistModel,
266 required: false
267 }
268 ]
269 },
270 [ScopeNames.WITH_WEBTORRENT_FILES]: (withRedundancies = false) => {
271 let subInclude: any[] = []
272
273 if (withRedundancies === true) {
274 subInclude = [
275 {
276 attributes: [ 'fileUrl' ],
277 model: VideoRedundancyModel.unscoped(),
278 required: false
279 }
280 ]
281 }
282
283 return {
284 include: [
285 {
286 model: VideoFileModel,
287 separate: true,
288 required: false,
289 include: subInclude
290 }
291 ]
292 }
293 },
294 [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => {
295 const subInclude: IncludeOptions[] = [
296 {
297 model: VideoFileModel,
298 required: false
299 }
300 ]
301
302 if (withRedundancies === true) {
303 subInclude.push({
304 attributes: [ 'fileUrl' ],
305 model: VideoRedundancyModel.unscoped(),
306 required: false
307 })
308 }
309
310 return {
311 include: [
312 {
313 model: VideoStreamingPlaylistModel.unscoped(),
314 required: false,
315 separate: true,
316 include: subInclude
317 }
318 ]
319 }
320 },
321 [ScopeNames.WITH_SCHEDULED_UPDATE]: {
322 include: [
323 {
324 model: ScheduleVideoUpdateModel.unscoped(),
325 required: false
326 }
327 ]
328 },
329 [ScopeNames.WITH_USER_HISTORY]: (userId: number) => {
330 return {
331 include: [
332 {
333 attributes: [ 'currentTime' ],
334 model: UserVideoHistoryModel.unscoped(),
335 required: false,
336 where: {
337 userId
338 }
339 }
340 ]
341 }
342 }
343 }))
344 @Table({
345 tableName: 'video',
346 indexes: [
347 buildTrigramSearchIndex('video_name_trigram', 'name'),
348
349 { fields: [ 'createdAt' ] },
350 {
351 fields: [
352 { name: 'publishedAt', order: 'DESC' },
353 { name: 'id', order: 'ASC' }
354 ]
355 },
356 { fields: [ 'duration' ] },
357 {
358 fields: [
359 { name: 'views', order: 'DESC' },
360 { name: 'id', order: 'ASC' }
361 ]
362 },
363 { fields: [ 'channelId' ] },
364 {
365 fields: [ 'originallyPublishedAt' ],
366 where: {
367 originallyPublishedAt: {
368 [Op.ne]: null
369 }
370 }
371 },
372 {
373 fields: [ 'category' ], // We don't care videos with an unknown category
374 where: {
375 category: {
376 [Op.ne]: null
377 }
378 }
379 },
380 {
381 fields: [ 'licence' ], // We don't care videos with an unknown licence
382 where: {
383 licence: {
384 [Op.ne]: null
385 }
386 }
387 },
388 {
389 fields: [ 'language' ], // We don't care videos with an unknown language
390 where: {
391 language: {
392 [Op.ne]: null
393 }
394 }
395 },
396 {
397 fields: [ 'nsfw' ], // Most of the videos are not NSFW
398 where: {
399 nsfw: true
400 }
401 },
402 {
403 fields: [ 'remote' ], // Only index local videos
404 where: {
405 remote: false
406 }
407 },
408 {
409 fields: [ 'uuid' ],
410 unique: true
411 },
412 {
413 fields: [ 'url' ],
414 unique: true
415 }
416 ]
417 })
418 export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
419
420 @AllowNull(false)
421 @Default(DataType.UUIDV4)
422 @IsUUID(4)
423 @Column(DataType.UUID)
424 uuid: string
425
426 @AllowNull(false)
427 @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
428 @Column
429 name: string
430
431 @AllowNull(true)
432 @Default(null)
433 @Column
434 category: number
435
436 @AllowNull(true)
437 @Default(null)
438 @Column
439 licence: number
440
441 @AllowNull(true)
442 @Default(null)
443 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
444 language: string
445
446 @AllowNull(false)
447 @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
448 @Column
449 privacy: VideoPrivacy
450
451 @AllowNull(false)
452 @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
453 @Column
454 nsfw: boolean
455
456 @AllowNull(true)
457 @Default(null)
458 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true))
459 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
460 description: string
461
462 @AllowNull(true)
463 @Default(null)
464 @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true))
465 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
466 support: string
467
468 @AllowNull(false)
469 @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
470 @Column
471 duration: number
472
473 @AllowNull(false)
474 @Default(0)
475 @IsInt
476 @Min(0)
477 @Column
478 views: number
479
480 @AllowNull(false)
481 @Default(0)
482 @IsInt
483 @Min(0)
484 @Column
485 likes: number
486
487 @AllowNull(false)
488 @Default(0)
489 @IsInt
490 @Min(0)
491 @Column
492 dislikes: number
493
494 @AllowNull(false)
495 @Column
496 remote: boolean
497
498 @AllowNull(false)
499 @Default(false)
500 @Column
501 isLive: boolean
502
503 @AllowNull(false)
504 @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
505 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
506 url: string
507
508 @AllowNull(false)
509 @Column
510 commentsEnabled: boolean
511
512 @AllowNull(false)
513 @Column
514 downloadEnabled: boolean
515
516 @AllowNull(false)
517 @Column
518 waitTranscoding: boolean
519
520 @AllowNull(false)
521 @Default(null)
522 @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
523 @Column
524 state: VideoState
525
526 @CreatedAt
527 createdAt: Date
528
529 @UpdatedAt
530 updatedAt: Date
531
532 @AllowNull(false)
533 @Default(DataType.NOW)
534 @Column
535 publishedAt: Date
536
537 @AllowNull(true)
538 @Default(null)
539 @Column
540 originallyPublishedAt: Date
541
542 @ForeignKey(() => VideoChannelModel)
543 @Column
544 channelId: number
545
546 @BelongsTo(() => VideoChannelModel, {
547 foreignKey: {
548 allowNull: true
549 },
550 onDelete: 'cascade'
551 })
552 VideoChannel: VideoChannelModel
553
554 @BelongsToMany(() => TagModel, {
555 foreignKey: 'videoId',
556 through: () => VideoTagModel,
557 onDelete: 'CASCADE'
558 })
559 Tags: TagModel[]
560
561 @BelongsToMany(() => TrackerModel, {
562 foreignKey: 'videoId',
563 through: () => VideoTrackerModel,
564 onDelete: 'CASCADE'
565 })
566 Trackers: TrackerModel[]
567
568 @HasMany(() => ThumbnailModel, {
569 foreignKey: {
570 name: 'videoId',
571 allowNull: true
572 },
573 hooks: true,
574 onDelete: 'cascade'
575 })
576 Thumbnails: ThumbnailModel[]
577
578 @HasMany(() => VideoPlaylistElementModel, {
579 foreignKey: {
580 name: 'videoId',
581 allowNull: true
582 },
583 onDelete: 'set null'
584 })
585 VideoPlaylistElements: VideoPlaylistElementModel[]
586
587 @HasMany(() => VideoAbuseModel, {
588 foreignKey: {
589 name: 'videoId',
590 allowNull: true
591 },
592 onDelete: 'set null'
593 })
594 VideoAbuses: VideoAbuseModel[]
595
596 @HasMany(() => VideoFileModel, {
597 foreignKey: {
598 name: 'videoId',
599 allowNull: true
600 },
601 hooks: true,
602 onDelete: 'cascade'
603 })
604 VideoFiles: VideoFileModel[]
605
606 @HasMany(() => VideoStreamingPlaylistModel, {
607 foreignKey: {
608 name: 'videoId',
609 allowNull: false
610 },
611 hooks: true,
612 onDelete: 'cascade'
613 })
614 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
615
616 @HasMany(() => VideoShareModel, {
617 foreignKey: {
618 name: 'videoId',
619 allowNull: false
620 },
621 onDelete: 'cascade'
622 })
623 VideoShares: VideoShareModel[]
624
625 @HasMany(() => AccountVideoRateModel, {
626 foreignKey: {
627 name: 'videoId',
628 allowNull: false
629 },
630 onDelete: 'cascade'
631 })
632 AccountVideoRates: AccountVideoRateModel[]
633
634 @HasMany(() => VideoCommentModel, {
635 foreignKey: {
636 name: 'videoId',
637 allowNull: false
638 },
639 onDelete: 'cascade',
640 hooks: true
641 })
642 VideoComments: VideoCommentModel[]
643
644 @HasMany(() => VideoViewModel, {
645 foreignKey: {
646 name: 'videoId',
647 allowNull: false
648 },
649 onDelete: 'cascade'
650 })
651 VideoViews: VideoViewModel[]
652
653 @HasMany(() => UserVideoHistoryModel, {
654 foreignKey: {
655 name: 'videoId',
656 allowNull: false
657 },
658 onDelete: 'cascade'
659 })
660 UserVideoHistories: UserVideoHistoryModel[]
661
662 @HasOne(() => ScheduleVideoUpdateModel, {
663 foreignKey: {
664 name: 'videoId',
665 allowNull: false
666 },
667 onDelete: 'cascade'
668 })
669 ScheduleVideoUpdate: ScheduleVideoUpdateModel
670
671 @HasOne(() => VideoBlacklistModel, {
672 foreignKey: {
673 name: 'videoId',
674 allowNull: false
675 },
676 onDelete: 'cascade'
677 })
678 VideoBlacklist: VideoBlacklistModel
679
680 @HasOne(() => VideoLiveModel, {
681 foreignKey: {
682 name: 'videoId',
683 allowNull: false
684 },
685 onDelete: 'cascade'
686 })
687 VideoLive: VideoLiveModel
688
689 @HasOne(() => VideoImportModel, {
690 foreignKey: {
691 name: 'videoId',
692 allowNull: true
693 },
694 onDelete: 'set null'
695 })
696 VideoImport: VideoImportModel
697
698 @HasMany(() => VideoCaptionModel, {
699 foreignKey: {
700 name: 'videoId',
701 allowNull: false
702 },
703 onDelete: 'cascade',
704 hooks: true,
705 ['separate' as any]: true
706 })
707 VideoCaptions: VideoCaptionModel[]
708
709 @HasOne(() => VideoJobInfoModel, {
710 foreignKey: {
711 name: 'videoId',
712 allowNull: false
713 },
714 onDelete: 'cascade'
715 })
716 VideoJobInfo: VideoJobInfoModel
717
718 @BeforeDestroy
719 static async sendDelete (instance: MVideoAccountLight, options) {
720 if (!instance.isOwned()) return undefined
721
722 // Lazy load channels
723 if (!instance.VideoChannel) {
724 instance.VideoChannel = await instance.$get('VideoChannel', {
725 include: [
726 ActorModel,
727 AccountModel
728 ],
729 transaction: options.transaction
730 }) as MChannelAccountDefault
731 }
732
733 return sendDeleteVideo(instance, options.transaction)
734 }
735
736 @BeforeDestroy
737 static async removeFiles (instance: VideoModel, options) {
738 const tasks: Promise<any>[] = []
739
740 logger.info('Removing files of video %s.', instance.url)
741
742 if (instance.isOwned()) {
743 if (!Array.isArray(instance.VideoFiles)) {
744 instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction })
745 }
746
747 // Remove physical files and torrents
748 instance.VideoFiles.forEach(file => {
749 tasks.push(instance.removeFileAndTorrent(file))
750 })
751
752 // Remove playlists file
753 if (!Array.isArray(instance.VideoStreamingPlaylists)) {
754 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction })
755 }
756
757 for (const p of instance.VideoStreamingPlaylists) {
758 tasks.push(instance.removeStreamingPlaylistFiles(p))
759 }
760 }
761
762 // Do not wait video deletion because we could be in a transaction
763 Promise.all(tasks)
764 .catch(err => {
765 logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
766 })
767
768 return undefined
769 }
770
771 @BeforeDestroy
772 static stopLiveIfNeeded (instance: VideoModel) {
773 if (!instance.isLive) return
774
775 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
776
777 LiveManager.Instance.stopSessionOf(instance.id)
778 }
779
780 @BeforeDestroy
781 static invalidateCache (instance: VideoModel) {
782 ModelCache.Instance.invalidateCache('video', instance.id)
783 }
784
785 @BeforeDestroy
786 static async saveEssentialDataToAbuses (instance: VideoModel, options) {
787 const tasks: Promise<any>[] = []
788
789 if (!Array.isArray(instance.VideoAbuses)) {
790 instance.VideoAbuses = await instance.$get('VideoAbuses', { transaction: options.transaction })
791
792 if (instance.VideoAbuses.length === 0) return undefined
793 }
794
795 logger.info('Saving video abuses details of video %s.', instance.url)
796
797 if (!instance.Trackers) instance.Trackers = await instance.$get('Trackers', { transaction: options.transaction })
798 const details = instance.toFormattedDetailsJSON()
799
800 for (const abuse of instance.VideoAbuses) {
801 abuse.deletedVideo = details
802 tasks.push(abuse.save({ transaction: options.transaction }))
803 }
804
805 await Promise.all(tasks)
806 }
807
808 static listLocalIds (): Promise<number[]> {
809 const query = {
810 attributes: [ 'id' ],
811 raw: true,
812 where: {
813 remote: false
814 }
815 }
816
817 return VideoModel.findAll(query)
818 .then(rows => rows.map(r => r.id))
819 }
820
821 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
822 function getRawQuery (select: string) {
823 const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
824 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
825 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
826 'WHERE "Account"."actorId" = ' + actorId
827 const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
828 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
829 'WHERE "VideoShare"."actorId" = ' + actorId
830
831 return `(${queryVideo}) UNION (${queryVideoShare})`
832 }
833
834 const rawQuery = getRawQuery('"Video"."id"')
835 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
836
837 const query = {
838 distinct: true,
839 offset: start,
840 limit: count,
841 order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ]),
842 where: {
843 id: {
844 [Op.in]: Sequelize.literal('(' + rawQuery + ')')
845 },
846 [Op.or]: getPrivaciesForFederation()
847 },
848 include: [
849 {
850 attributes: [ 'filename', 'language', 'fileUrl' ],
851 model: VideoCaptionModel.unscoped(),
852 required: false
853 },
854 {
855 attributes: [ 'id', 'url' ],
856 model: VideoShareModel.unscoped(),
857 required: false,
858 // We only want videos shared by this actor
859 where: {
860 [Op.and]: [
861 {
862 id: {
863 [Op.not]: null
864 }
865 },
866 {
867 actorId
868 }
869 ]
870 },
871 include: [
872 {
873 attributes: [ 'id', 'url' ],
874 model: ActorModel.unscoped()
875 }
876 ]
877 },
878 {
879 model: VideoChannelModel.unscoped(),
880 required: true,
881 include: [
882 {
883 attributes: [ 'name' ],
884 model: AccountModel.unscoped(),
885 required: true,
886 include: [
887 {
888 attributes: [ 'id', 'url', 'followersUrl' ],
889 model: ActorModel.unscoped(),
890 required: true
891 }
892 ]
893 },
894 {
895 attributes: [ 'id', 'url', 'followersUrl' ],
896 model: ActorModel.unscoped(),
897 required: true
898 }
899 ]
900 },
901 {
902 model: VideoStreamingPlaylistModel.unscoped(),
903 required: false,
904 include: [
905 {
906 model: VideoFileModel,
907 required: false
908 }
909 ]
910 },
911 VideoLiveModel.unscoped(),
912 VideoFileModel,
913 TagModel
914 ]
915 }
916
917 return Bluebird.all([
918 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
919 VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
920 ]).then(([ rows, totals ]) => {
921 // totals: totalVideos + totalVideoShares
922 let totalVideos = 0
923 let totalVideoShares = 0
924 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
925 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
926
927 const total = totalVideos + totalVideoShares
928 return {
929 data: rows,
930 total: total
931 }
932 })
933 }
934
935 static async listPublishedLiveUUIDs () {
936 const options = {
937 attributes: [ 'uuid' ],
938 where: {
939 isLive: true,
940 remote: false,
941 state: VideoState.PUBLISHED
942 }
943 }
944
945 const result = await VideoModel.findAll(options)
946
947 return result.map(v => v.uuid)
948 }
949
950 static listUserVideosForApi (options: {
951 accountId: number
952 start: number
953 count: number
954 sort: string
955
956 channelId?: number
957 isLive?: boolean
958 search?: string
959 }) {
960 const { accountId, channelId, start, count, sort, search, isLive } = options
961
962 function buildBaseQuery (): FindOptions {
963 const where: WhereOptions = {}
964
965 if (search) {
966 where.name = {
967 [Op.iLike]: '%' + search + '%'
968 }
969 }
970
971 if (exists(isLive)) {
972 where.isLive = isLive
973 }
974
975 const channelWhere = channelId
976 ? { id: channelId }
977 : {}
978
979 const baseQuery = {
980 offset: start,
981 limit: count,
982 where,
983 order: getVideoSort(sort),
984 include: [
985 {
986 model: VideoChannelModel,
987 required: true,
988 where: channelWhere,
989 include: [
990 {
991 model: AccountModel,
992 where: {
993 id: accountId
994 },
995 required: true
996 }
997 ]
998 }
999 ]
1000 }
1001
1002 return baseQuery
1003 }
1004
1005 const countQuery = buildBaseQuery()
1006 const findQuery = buildBaseQuery()
1007
1008 const findScopes: (string | ScopeOptions)[] = [
1009 ScopeNames.WITH_SCHEDULED_UPDATE,
1010 ScopeNames.WITH_BLACKLISTED,
1011 ScopeNames.WITH_THUMBNAILS
1012 ]
1013
1014 return Promise.all([
1015 VideoModel.count(countQuery),
1016 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1017 ]).then(([ count, rows ]) => {
1018 return {
1019 data: rows,
1020 total: count
1021 }
1022 })
1023 }
1024
1025 static async listForApi (options: {
1026 start: number
1027 count: number
1028 sort: string
1029
1030 nsfw: boolean
1031 isLive?: boolean
1032 isLocal?: boolean
1033 include?: VideoInclude
1034
1035 hasFiles?: boolean // default false
1036 hasWebtorrentFiles?: boolean
1037 hasHLSFiles?: boolean
1038
1039 categoryOneOf?: number[]
1040 licenceOneOf?: number[]
1041 languageOneOf?: string[]
1042 tagsOneOf?: string[]
1043 tagsAllOf?: string[]
1044
1045 accountId?: number
1046 videoChannelId?: number
1047
1048 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1049
1050 videoPlaylistId?: number
1051
1052 trendingDays?: number
1053
1054 user?: MUserAccountId
1055 historyOfUser?: MUserId
1056
1057 countVideos?: boolean
1058
1059 search?: string
1060 }) {
1061 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1062
1063 const trendingDays = options.sort.endsWith('trending')
1064 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1065 : undefined
1066
1067 let trendingAlgorithm: string
1068 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1069 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1070
1071 const serverActor = await getServerActor()
1072
1073 const queryOptions = {
1074 ...pick(options, [
1075 'start',
1076 'count',
1077 'sort',
1078 'nsfw',
1079 'isLive',
1080 'categoryOneOf',
1081 'licenceOneOf',
1082 'languageOneOf',
1083 'tagsOneOf',
1084 'tagsAllOf',
1085 'isLocal',
1086 'include',
1087 'displayOnlyForFollower',
1088 'hasFiles',
1089 'accountId',
1090 'videoChannelId',
1091 'videoPlaylistId',
1092 'user',
1093 'historyOfUser',
1094 'hasHLSFiles',
1095 'hasWebtorrentFiles',
1096 'search'
1097 ]),
1098
1099 serverAccountIdForBlock: serverActor.Account.id,
1100 trendingDays,
1101 trendingAlgorithm
1102 }
1103
1104 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1105 }
1106
1107 static async searchAndPopulateAccountAndServer (options: {
1108 start: number
1109 count: number
1110 sort: string
1111
1112 nsfw?: boolean
1113 isLive?: boolean
1114 isLocal?: boolean
1115 include?: VideoInclude
1116
1117 categoryOneOf?: number[]
1118 licenceOneOf?: number[]
1119 languageOneOf?: string[]
1120 tagsOneOf?: string[]
1121 tagsAllOf?: string[]
1122
1123 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1124
1125 user?: MUserAccountId
1126
1127 hasWebtorrentFiles?: boolean
1128 hasHLSFiles?: boolean
1129
1130 search?: string
1131
1132 host?: string
1133 startDate?: string // ISO 8601
1134 endDate?: string // ISO 8601
1135 originallyPublishedStartDate?: string
1136 originallyPublishedEndDate?: string
1137
1138 durationMin?: number // seconds
1139 durationMax?: number // seconds
1140 uuids?: string[]
1141 }) {
1142 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1143
1144 const serverActor = await getServerActor()
1145
1146 const queryOptions = {
1147 ...pick(options, [
1148 'include',
1149 'nsfw',
1150 'isLive',
1151 'categoryOneOf',
1152 'licenceOneOf',
1153 'languageOneOf',
1154 'tagsOneOf',
1155 'tagsAllOf',
1156 'user',
1157 'isLocal',
1158 'host',
1159 'start',
1160 'count',
1161 'sort',
1162 'startDate',
1163 'endDate',
1164 'originallyPublishedStartDate',
1165 'originallyPublishedEndDate',
1166 'durationMin',
1167 'durationMax',
1168 'hasHLSFiles',
1169 'hasWebtorrentFiles',
1170 'uuids',
1171 'search',
1172 'displayOnlyForFollower'
1173 ]),
1174 serverAccountIdForBlock: serverActor.Account.id
1175 }
1176
1177 return VideoModel.getAvailableForApi(queryOptions)
1178 }
1179
1180 static countLocalLives () {
1181 const options = {
1182 where: {
1183 remote: false,
1184 isLive: true,
1185 state: {
1186 [Op.ne]: VideoState.LIVE_ENDED
1187 }
1188 }
1189 }
1190
1191 return VideoModel.count(options)
1192 }
1193
1194 static countVideosUploadedByUserSince (userId: number, since: Date) {
1195 const options = {
1196 include: [
1197 {
1198 model: VideoChannelModel.unscoped(),
1199 required: true,
1200 include: [
1201 {
1202 model: AccountModel.unscoped(),
1203 required: true,
1204 include: [
1205 {
1206 model: UserModel.unscoped(),
1207 required: true,
1208 where: {
1209 id: userId
1210 }
1211 }
1212 ]
1213 }
1214 ]
1215 }
1216 ],
1217 where: {
1218 createdAt: {
1219 [Op.gte]: since
1220 }
1221 }
1222 }
1223
1224 return VideoModel.unscoped().count(options)
1225 }
1226
1227 static countLivesOfAccount (accountId: number) {
1228 const options = {
1229 where: {
1230 remote: false,
1231 isLive: true,
1232 state: {
1233 [Op.ne]: VideoState.LIVE_ENDED
1234 }
1235 },
1236 include: [
1237 {
1238 required: true,
1239 model: VideoChannelModel.unscoped(),
1240 where: {
1241 accountId
1242 }
1243 }
1244 ]
1245 }
1246
1247 return VideoModel.count(options)
1248 }
1249
1250 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1251 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1252
1253 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1254 }
1255
1256 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1257 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1258
1259 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1260 }
1261
1262 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1263 const fun = () => {
1264 const query = {
1265 where: buildWhereIdOrUUID(id),
1266 transaction: t
1267 }
1268
1269 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1270 }
1271
1272 return ModelCache.Instance.doCache({
1273 cacheType: 'load-video-immutable-id',
1274 key: '' + id,
1275 deleteKey: 'video',
1276 fun
1277 })
1278 }
1279
1280 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1281 const fun = () => {
1282 const query: FindOptions = {
1283 where: {
1284 url
1285 },
1286 transaction
1287 }
1288
1289 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1290 }
1291
1292 return ModelCache.Instance.doCache({
1293 cacheType: 'load-video-immutable-url',
1294 key: url,
1295 deleteKey: 'video',
1296 fun
1297 })
1298 }
1299
1300 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1301 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1302
1303 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1304 }
1305
1306 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1307 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1308
1309 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1310 }
1311
1312 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1313 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1314
1315 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1316 }
1317
1318 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1319 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1320
1321 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1322 }
1323
1324 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1325 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1326
1327 return queryBuilder.queryVideo({ id, transaction: t, type: 'full-light', userId })
1328 }
1329
1330 static loadForGetAPI (parameters: {
1331 id: number | string
1332 transaction?: Transaction
1333 userId?: number
1334 }): Promise<MVideoDetails> {
1335 const { id, transaction, userId } = parameters
1336 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1337
1338 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1339 }
1340
1341 static async getStats () {
1342 const totalLocalVideos = await VideoModel.count({
1343 where: {
1344 remote: false
1345 }
1346 })
1347
1348 let totalLocalVideoViews = await VideoModel.sum('views', {
1349 where: {
1350 remote: false
1351 }
1352 })
1353
1354 // Sequelize could return null...
1355 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1356
1357 const serverActor = await getServerActor()
1358
1359 const { total: totalVideos } = await VideoModel.listForApi({
1360 start: 0,
1361 count: 0,
1362 sort: '-publishedAt',
1363 nsfw: buildNSFWFilter(),
1364 displayOnlyForFollower: {
1365 actorId: serverActor.id,
1366 orLocalVideos: true
1367 }
1368 })
1369
1370 return {
1371 totalLocalVideos,
1372 totalLocalVideoViews,
1373 totalVideos
1374 }
1375 }
1376
1377 static incrementViews (id: number, views: number) {
1378 return VideoModel.increment('views', {
1379 by: views,
1380 where: {
1381 id
1382 }
1383 })
1384 }
1385
1386 static updateRatesOf (videoId: number, type: VideoRateType, t: Transaction) {
1387 const field = type === 'like'
1388 ? 'likes'
1389 : 'dislikes'
1390
1391 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1392 '(' +
1393 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1394 ') ' +
1395 'WHERE "video"."id" = :videoId'
1396
1397 return AccountVideoRateModel.sequelize.query(rawQuery, {
1398 transaction: t,
1399 replacements: { videoId, rateType: type },
1400 type: QueryTypes.UPDATE
1401 })
1402 }
1403
1404 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1405 // Instances only share videos
1406 const query = 'SELECT 1 FROM "videoShare" ' +
1407 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1408 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1409 'LIMIT 1'
1410
1411 const options = {
1412 type: QueryTypes.SELECT as QueryTypes.SELECT,
1413 bind: { followerActorId, videoId },
1414 raw: true
1415 }
1416
1417 return VideoModel.sequelize.query(query, options)
1418 .then(results => results.length === 1)
1419 }
1420
1421 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1422 const options = {
1423 where: {
1424 channelId: ofChannel.id
1425 },
1426 transaction: t
1427 }
1428
1429 return VideoModel.update({ support: ofChannel.support }, options)
1430 }
1431
1432 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1433 const query = {
1434 attributes: [ 'id' ],
1435 where: {
1436 channelId: videoChannel.id
1437 }
1438 }
1439
1440 return VideoModel.findAll(query)
1441 .then(videos => videos.map(v => v.id))
1442 }
1443
1444 // threshold corresponds to how many video the field should have to be returned
1445 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1446 const serverActor = await getServerActor()
1447
1448 const queryOptions: BuildVideosListQueryOptions = {
1449 attributes: [ `"${field}"` ],
1450 group: `GROUP BY "${field}"`,
1451 having: `HAVING COUNT("${field}") >= ${threshold}`,
1452 start: 0,
1453 sort: 'random',
1454 count,
1455 serverAccountIdForBlock: serverActor.Account.id,
1456 displayOnlyForFollower: {
1457 actorId: serverActor.id,
1458 orLocalVideos: true
1459 }
1460 }
1461
1462 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1463
1464 return queryBuilder.queryVideoIds(queryOptions)
1465 .then(rows => rows.map(r => r[field]))
1466 }
1467
1468 static buildTrendingQuery (trendingDays: number) {
1469 return {
1470 attributes: [],
1471 subQuery: false,
1472 model: VideoViewModel,
1473 required: false,
1474 where: {
1475 startDate: {
1476 [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1477 }
1478 }
1479 }
1480 }
1481
1482 private static async getAvailableForApi (
1483 options: BuildVideosListQueryOptions,
1484 countVideos = true
1485 ): Promise<ResultList<VideoModel>> {
1486 function getCount () {
1487 if (countVideos !== true) return Promise.resolve(undefined)
1488
1489 const countOptions = Object.assign({}, options, { isCount: true })
1490 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1491
1492 return queryBuilder.countVideoIds(countOptions)
1493 }
1494
1495 function getModels () {
1496 if (options.count === 0) return Promise.resolve([])
1497
1498 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1499
1500 return queryBuilder.queryVideos(options)
1501 }
1502
1503 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1504
1505 return {
1506 data: rows,
1507 total: count
1508 }
1509 }
1510
1511 private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
1512 if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1513 throw new Error('Try to filter all-local but no user has not the see all videos right')
1514 }
1515 }
1516
1517 private static isPrivateInclude (include: VideoInclude) {
1518 return include & VideoInclude.BLACKLISTED ||
1519 include & VideoInclude.BLOCKED_OWNER ||
1520 include & VideoInclude.HIDDEN_PRIVACY ||
1521 include & VideoInclude.NOT_PUBLISHED_STATE
1522 }
1523
1524 isBlacklisted () {
1525 return !!this.VideoBlacklist
1526 }
1527
1528 isBlocked () {
1529 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1530 }
1531
1532 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1533 // We first transcode to WebTorrent format, so try this array first
1534 if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
1535 const file = fun(this.VideoFiles, file => file.resolution)
1536
1537 return Object.assign(file, { Video: this })
1538 }
1539
1540 // No webtorrent files, try with streaming playlist files
1541 if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
1542 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1543
1544 const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
1545 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1546 }
1547
1548 return undefined
1549 }
1550
1551 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1552 return this.getQualityFileBy(maxBy)
1553 }
1554
1555 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1556 return this.getQualityFileBy(minBy)
1557 }
1558
1559 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1560 if (Array.isArray(this.VideoFiles) === false) return undefined
1561
1562 const file = this.VideoFiles.find(f => f.resolution === resolution)
1563 if (!file) return undefined
1564
1565 return Object.assign(file, { Video: this })
1566 }
1567
1568 hasWebTorrentFiles () {
1569 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1570 }
1571
1572 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1573 thumbnail.videoId = this.id
1574
1575 const savedThumbnail = await thumbnail.save({ transaction })
1576
1577 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1578
1579 this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
1580 this.Thumbnails.push(savedThumbnail)
1581 }
1582
1583 getMiniature () {
1584 if (Array.isArray(this.Thumbnails) === false) return undefined
1585
1586 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1587 }
1588
1589 hasPreview () {
1590 return !!this.getPreview()
1591 }
1592
1593 getPreview () {
1594 if (Array.isArray(this.Thumbnails) === false) return undefined
1595
1596 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1597 }
1598
1599 isOwned () {
1600 return this.remote === false
1601 }
1602
1603 getWatchStaticPath () {
1604 return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
1605 }
1606
1607 getEmbedStaticPath () {
1608 return buildVideoEmbedPath(this)
1609 }
1610
1611 getMiniatureStaticPath () {
1612 const thumbnail = this.getMiniature()
1613 if (!thumbnail) return null
1614
1615 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1616 }
1617
1618 getPreviewStaticPath () {
1619 const preview = this.getPreview()
1620 if (!preview) return null
1621
1622 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1623 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1624 }
1625
1626 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1627 return videoModelToFormattedJSON(this, options)
1628 }
1629
1630 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1631 return videoModelToFormattedDetailsJSON(this)
1632 }
1633
1634 getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
1635 let files: VideoFile[] = []
1636
1637 if (Array.isArray(this.VideoFiles)) {
1638 const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet)
1639 files = files.concat(result)
1640 }
1641
1642 for (const p of (this.VideoStreamingPlaylists || [])) {
1643 const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet)
1644 files = files.concat(result)
1645 }
1646
1647 return files
1648 }
1649
1650 toActivityPubObject (this: MVideoAP): VideoObject {
1651 return videoModelToActivityPubObject(this)
1652 }
1653
1654 getTruncatedDescription () {
1655 if (!this.description) return null
1656
1657 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1658 return peertubeTruncate(this.description, { length: maxLength })
1659 }
1660
1661 getMaxQualityResolution () {
1662 const file = this.getMaxQualityFile()
1663 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1664
1665 return VideoPathManager.Instance.makeAvailableVideoFile(videoOrPlaylist, file, originalFilePath => {
1666 return getVideoFileResolution(originalFilePath)
1667 })
1668 }
1669
1670 getDescriptionAPIPath () {
1671 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1672 }
1673
1674 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1675 if (!this.VideoStreamingPlaylists) return undefined
1676
1677 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1678 if (!playlist) return undefined
1679
1680 playlist.Video = this
1681
1682 return playlist
1683 }
1684
1685 setHLSPlaylist (playlist: MStreamingPlaylist) {
1686 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1687
1688 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1689 this.VideoStreamingPlaylists = toAdd
1690 return
1691 }
1692
1693 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1694 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1695 .concat(toAdd)
1696 }
1697
1698 removeFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) {
1699 const filePath = isRedundancy
1700 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1701 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
1702
1703 const promises: Promise<any>[] = [ remove(filePath) ]
1704 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1705
1706 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1707 promises.push(removeWebTorrentObjectStorage(videoFile))
1708 }
1709
1710 return Promise.all(promises)
1711 }
1712
1713 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1714 const directoryPath = isRedundancy
1715 ? getHLSRedundancyDirectory(this)
1716 : getHLSDirectory(this)
1717
1718 await remove(directoryPath)
1719
1720 if (isRedundancy !== true) {
1721 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1722 streamingPlaylistWithFiles.Video = this
1723
1724 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1725 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1726 }
1727
1728 // Remove physical files and torrents
1729 await Promise.all(
1730 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
1731 )
1732
1733 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1734 await removeHLSObjectStorage(streamingPlaylist, this)
1735 }
1736 }
1737 }
1738
1739 isOutdated () {
1740 if (this.isOwned()) return false
1741
1742 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1743 }
1744
1745 hasPrivacyForFederation () {
1746 return isPrivacyForFederation(this.privacy)
1747 }
1748
1749 hasStateForFederation () {
1750 return isStateForFederation(this.state)
1751 }
1752
1753 isNewVideo (newPrivacy: VideoPrivacy) {
1754 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1755 }
1756
1757 setAsRefreshed () {
1758 return setAsUpdated('video', this.id)
1759 }
1760
1761 requiresAuth () {
1762 return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
1763 }
1764
1765 setPrivacy (newPrivacy: VideoPrivacy) {
1766 if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
1767 this.publishedAt = new Date()
1768 }
1769
1770 this.privacy = newPrivacy
1771 }
1772
1773 isConfidential () {
1774 return this.privacy === VideoPrivacy.PRIVATE ||
1775 this.privacy === VideoPrivacy.UNLISTED ||
1776 this.privacy === VideoPrivacy.INTERNAL
1777 }
1778
1779 async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
1780 if (this.state === newState) throw new Error('Cannot use same state ' + newState)
1781
1782 this.state = newState
1783
1784 if (this.state === VideoState.PUBLISHED && isNewVideo) {
1785 this.publishedAt = new Date()
1786 }
1787
1788 await this.save({ transaction })
1789 }
1790
1791 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
1792 return Math.ceil((videoFile.size * 8) / this.duration)
1793 }
1794
1795 getTrackerUrls () {
1796 if (this.isOwned()) {
1797 return [
1798 WEBSERVER.URL + '/tracker/announce',
1799 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
1800 ]
1801 }
1802
1803 return this.Trackers.map(t => t.url)
1804 }
1805 }