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