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