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