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