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