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