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