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