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