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