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