]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
cd3245ee400c45d8805c8dcdea1c13bdae017437
[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?: boolean
1289 }) {
1290 if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1291 throw new Error('Try to filter all-local but no user has not the see all videos right')
1292 }
1293
1294 const query: FindOptions & { where?: null } = {
1295 offset: options.start,
1296 limit: options.count,
1297 order: getVideoSort(options.sort)
1298 }
1299
1300 let trendingDays: number
1301 if (options.sort.endsWith('trending')) {
1302 trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1303
1304 query.group = 'VideoModel.id'
1305 }
1306
1307 const serverActor = await getServerActor()
1308
1309 // followerActorId === null has a meaning, so just check undefined
1310 const followerActorId = options.followerActorId !== undefined ? options.followerActorId : serverActor.id
1311
1312 const queryOptions = {
1313 followerActorId,
1314 serverAccountId: serverActor.Account.id,
1315 nsfw: options.nsfw,
1316 categoryOneOf: options.categoryOneOf,
1317 licenceOneOf: options.licenceOneOf,
1318 languageOneOf: options.languageOneOf,
1319 tagsOneOf: options.tagsOneOf,
1320 tagsAllOf: options.tagsAllOf,
1321 filter: options.filter,
1322 withFiles: options.withFiles,
1323 accountId: options.accountId,
1324 videoChannelId: options.videoChannelId,
1325 videoPlaylistId: options.videoPlaylistId,
1326 includeLocalVideos: options.includeLocalVideos,
1327 user: options.user,
1328 historyOfUser: options.historyOfUser,
1329 trendingDays
1330 }
1331
1332 return VideoModel.getAvailableForApi(query, queryOptions, options.countVideos)
1333 }
1334
1335 static async searchAndPopulateAccountAndServer (options: {
1336 includeLocalVideos: boolean
1337 search?: string
1338 start?: number
1339 count?: number
1340 sort?: string
1341 startDate?: string // ISO 8601
1342 endDate?: string // ISO 8601
1343 originallyPublishedStartDate?: string
1344 originallyPublishedEndDate?: string
1345 nsfw?: boolean
1346 categoryOneOf?: number[]
1347 licenceOneOf?: number[]
1348 languageOneOf?: string[]
1349 tagsOneOf?: string[]
1350 tagsAllOf?: string[]
1351 durationMin?: number // seconds
1352 durationMax?: number // seconds
1353 user?: MUserAccountId,
1354 filter?: VideoFilter
1355 }) {
1356 const whereAnd = []
1357
1358 if (options.startDate || options.endDate) {
1359 const publishedAtRange = {}
1360
1361 if (options.startDate) publishedAtRange[ Op.gte ] = options.startDate
1362 if (options.endDate) publishedAtRange[ Op.lte ] = options.endDate
1363
1364 whereAnd.push({ publishedAt: publishedAtRange })
1365 }
1366
1367 if (options.originallyPublishedStartDate || options.originallyPublishedEndDate) {
1368 const originallyPublishedAtRange = {}
1369
1370 if (options.originallyPublishedStartDate) originallyPublishedAtRange[ Op.gte ] = options.originallyPublishedStartDate
1371 if (options.originallyPublishedEndDate) originallyPublishedAtRange[ Op.lte ] = options.originallyPublishedEndDate
1372
1373 whereAnd.push({ originallyPublishedAt: originallyPublishedAtRange })
1374 }
1375
1376 if (options.durationMin || options.durationMax) {
1377 const durationRange = {}
1378
1379 if (options.durationMin) durationRange[ Op.gte ] = options.durationMin
1380 if (options.durationMax) durationRange[ Op.lte ] = options.durationMax
1381
1382 whereAnd.push({ duration: durationRange })
1383 }
1384
1385 const attributesInclude = []
1386 const escapedSearch = VideoModel.sequelize.escape(options.search)
1387 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
1388 if (options.search) {
1389 const trigramSearch = {
1390 id: {
1391 [ Op.in ]: Sequelize.literal(
1392 '(' +
1393 'SELECT "video"."id" FROM "video" ' +
1394 'WHERE ' +
1395 'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
1396 'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
1397 'UNION ALL ' +
1398 'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
1399 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
1400 'WHERE lower("tag"."name") = lower(' + escapedSearch + ')' +
1401 ')'
1402 )
1403 }
1404 }
1405
1406 if (validator.isUUID(options.search)) {
1407 whereAnd.push({
1408 [Op.or]: [
1409 trigramSearch,
1410 {
1411 uuid: options.search
1412 }
1413 ]
1414 })
1415 } else {
1416 whereAnd.push(trigramSearch)
1417 }
1418
1419 attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search))
1420 }
1421
1422 // Cannot search on similarity if we don't have a search
1423 if (!options.search) {
1424 attributesInclude.push(
1425 Sequelize.literal('0 as similarity')
1426 )
1427 }
1428
1429 const query = {
1430 attributes: {
1431 include: attributesInclude
1432 },
1433 offset: options.start,
1434 limit: options.count,
1435 order: getVideoSort(options.sort)
1436 }
1437
1438 const serverActor = await getServerActor()
1439 const queryOptions = {
1440 followerActorId: serverActor.id,
1441 serverAccountId: serverActor.Account.id,
1442 includeLocalVideos: options.includeLocalVideos,
1443 nsfw: options.nsfw,
1444 categoryOneOf: options.categoryOneOf,
1445 licenceOneOf: options.licenceOneOf,
1446 languageOneOf: options.languageOneOf,
1447 tagsOneOf: options.tagsOneOf,
1448 tagsAllOf: options.tagsAllOf,
1449 user: options.user,
1450 filter: options.filter,
1451 baseWhere: whereAnd
1452 }
1453
1454 return VideoModel.getAvailableForApi(query, queryOptions)
1455 }
1456
1457 static load (id: number | string, t?: Transaction): Bluebird<MVideoThumbnail> {
1458 const where = buildWhereIdOrUUID(id)
1459 const options = {
1460 where,
1461 transaction: t
1462 }
1463
1464 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1465 }
1466
1467 static loadWithBlacklist (id: number | string, t?: Transaction): Bluebird<MVideoThumbnailBlacklist> {
1468 const where = buildWhereIdOrUUID(id)
1469 const options = {
1470 where,
1471 transaction: t
1472 }
1473
1474 return VideoModel.scope([
1475 ScopeNames.WITH_THUMBNAILS,
1476 ScopeNames.WITH_BLACKLISTED
1477 ]).findOne(options)
1478 }
1479
1480 static loadWithRights (id: number | string, t?: Transaction): Bluebird<MVideoWithRights> {
1481 const where = buildWhereIdOrUUID(id)
1482 const options = {
1483 where,
1484 transaction: t
1485 }
1486
1487 return VideoModel.scope([
1488 ScopeNames.WITH_BLACKLISTED,
1489 ScopeNames.WITH_USER_ID,
1490 ScopeNames.WITH_THUMBNAILS
1491 ]).findOne(options)
1492 }
1493
1494 static loadOnlyId (id: number | string, t?: Transaction): Bluebird<MVideoIdThumbnail> {
1495 const where = buildWhereIdOrUUID(id)
1496
1497 const options = {
1498 attributes: [ 'id' ],
1499 where,
1500 transaction: t
1501 }
1502
1503 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1504 }
1505
1506 static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Bluebird<MVideoWithAllFiles> {
1507 const where = buildWhereIdOrUUID(id)
1508
1509 const query = {
1510 where,
1511 transaction: t,
1512 logging
1513 }
1514
1515 return VideoModel.scope([
1516 ScopeNames.WITH_WEBTORRENT_FILES,
1517 ScopeNames.WITH_STREAMING_PLAYLISTS,
1518 ScopeNames.WITH_THUMBNAILS
1519 ]).findOne(query)
1520 }
1521
1522 static loadByUUID (uuid: string): Bluebird<MVideoThumbnail> {
1523 const options = {
1524 where: {
1525 uuid
1526 }
1527 }
1528
1529 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1530 }
1531
1532 static loadByUrl (url: string, transaction?: Transaction): Bluebird<MVideoThumbnail> {
1533 const query: FindOptions = {
1534 where: {
1535 url
1536 },
1537 transaction
1538 }
1539
1540 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1541 }
1542
1543 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Bluebird<MVideoAccountLightBlacklistAllFiles> {
1544 const query: FindOptions = {
1545 where: {
1546 url
1547 },
1548 transaction
1549 }
1550
1551 return VideoModel.scope([
1552 ScopeNames.WITH_ACCOUNT_DETAILS,
1553 ScopeNames.WITH_WEBTORRENT_FILES,
1554 ScopeNames.WITH_STREAMING_PLAYLISTS,
1555 ScopeNames.WITH_THUMBNAILS,
1556 ScopeNames.WITH_BLACKLISTED
1557 ]).findOne(query)
1558 }
1559
1560 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Bluebird<MVideoFullLight> {
1561 const where = buildWhereIdOrUUID(id)
1562
1563 const options = {
1564 order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
1565 where,
1566 transaction: t
1567 }
1568
1569 const scopes: (string | ScopeOptions)[] = [
1570 ScopeNames.WITH_TAGS,
1571 ScopeNames.WITH_BLACKLISTED,
1572 ScopeNames.WITH_ACCOUNT_DETAILS,
1573 ScopeNames.WITH_SCHEDULED_UPDATE,
1574 ScopeNames.WITH_WEBTORRENT_FILES,
1575 ScopeNames.WITH_STREAMING_PLAYLISTS,
1576 ScopeNames.WITH_THUMBNAILS
1577 ]
1578
1579 if (userId) {
1580 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1581 }
1582
1583 return VideoModel
1584 .scope(scopes)
1585 .findOne(options)
1586 }
1587
1588 static loadForGetAPI (parameters: {
1589 id: number | string,
1590 t?: Transaction,
1591 userId?: number
1592 }): Bluebird<MVideoDetails> {
1593 const { id, t, userId } = parameters
1594 const where = buildWhereIdOrUUID(id)
1595
1596 const options = {
1597 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1598 where,
1599 transaction: t
1600 }
1601
1602 const scopes: (string | ScopeOptions)[] = [
1603 ScopeNames.WITH_TAGS,
1604 ScopeNames.WITH_BLACKLISTED,
1605 ScopeNames.WITH_ACCOUNT_DETAILS,
1606 ScopeNames.WITH_SCHEDULED_UPDATE,
1607 ScopeNames.WITH_THUMBNAILS,
1608 { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] },
1609 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1610 ]
1611
1612 if (userId) {
1613 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1614 }
1615
1616 return VideoModel
1617 .scope(scopes)
1618 .findOne(options)
1619 }
1620
1621 static async getStats () {
1622 const totalLocalVideos = await VideoModel.count({
1623 where: {
1624 remote: false
1625 }
1626 })
1627 const totalVideos = await VideoModel.count()
1628
1629 let totalLocalVideoViews = await VideoModel.sum('views', {
1630 where: {
1631 remote: false
1632 }
1633 })
1634 // Sequelize could return null...
1635 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1636
1637 return {
1638 totalLocalVideos,
1639 totalLocalVideoViews,
1640 totalVideos
1641 }
1642 }
1643
1644 static incrementViews (id: number, views: number) {
1645 return VideoModel.increment('views', {
1646 by: views,
1647 where: {
1648 id
1649 }
1650 })
1651 }
1652
1653 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1654 // Instances only share videos
1655 const query = 'SELECT 1 FROM "videoShare" ' +
1656 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1657 'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' +
1658 'LIMIT 1'
1659
1660 const options = {
1661 type: QueryTypes.SELECT as QueryTypes.SELECT,
1662 bind: { followerActorId, videoId },
1663 raw: true
1664 }
1665
1666 return VideoModel.sequelize.query(query, options)
1667 .then(results => results.length === 1)
1668 }
1669
1670 static bulkUpdateSupportField (videoChannel: MChannel, t: Transaction) {
1671 const options = {
1672 where: {
1673 channelId: videoChannel.id
1674 },
1675 transaction: t
1676 }
1677
1678 return VideoModel.update({ support: videoChannel.support }, options)
1679 }
1680
1681 static getAllIdsFromChannel (videoChannel: MChannelId): Bluebird<number[]> {
1682 const query = {
1683 attributes: [ 'id' ],
1684 where: {
1685 channelId: videoChannel.id
1686 }
1687 }
1688
1689 return VideoModel.findAll(query)
1690 .then(videos => videos.map(v => v.id))
1691 }
1692
1693 // threshold corresponds to how many video the field should have to be returned
1694 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1695 const serverActor = await getServerActor()
1696 const followerActorId = serverActor.id
1697
1698 const scopeOptions: AvailableForListIDsOptions = {
1699 serverAccountId: serverActor.Account.id,
1700 followerActorId,
1701 includeLocalVideos: true,
1702 attributesType: 'none' // Don't break aggregation
1703 }
1704
1705 const query: FindOptions = {
1706 attributes: [ field ],
1707 limit: count,
1708 group: field,
1709 having: Sequelize.where(
1710 Sequelize.fn('COUNT', Sequelize.col(field)), { [ Op.gte ]: threshold }
1711 ),
1712 order: [ (this.sequelize as any).random() ]
1713 }
1714
1715 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
1716 .findAll(query)
1717 .then(rows => rows.map(r => r[ field ]))
1718 }
1719
1720 static buildTrendingQuery (trendingDays: number) {
1721 return {
1722 attributes: [],
1723 subQuery: false,
1724 model: VideoViewModel,
1725 required: false,
1726 where: {
1727 startDate: {
1728 [ Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1729 }
1730 }
1731 }
1732 }
1733
1734 private static async getAvailableForApi (
1735 query: FindOptions & { where?: null }, // Forbid where field in query
1736 options: AvailableForListIDsOptions,
1737 countVideos = true
1738 ) {
1739 const idsScope: ScopeOptions = {
1740 method: [
1741 ScopeNames.AVAILABLE_FOR_LIST_IDS, options
1742 ]
1743 }
1744
1745 // Remove trending sort on count, because it uses a group by
1746 const countOptions = Object.assign({}, options, { trendingDays: undefined })
1747 const countQuery: CountOptions = Object.assign({}, query, { attributes: undefined, group: undefined })
1748 const countScope: ScopeOptions = {
1749 method: [
1750 ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions
1751 ]
1752 }
1753
1754 const [ count, rows ] = await Promise.all([
1755 countVideos
1756 ? VideoModel.scope(countScope).count(countQuery)
1757 : Promise.resolve<number>(undefined),
1758
1759 VideoModel.scope(idsScope)
1760 .findAll(Object.assign({}, query, { raw: true }))
1761 .then(rows => rows.map(r => r.id))
1762 .then(ids => VideoModel.loadCompleteVideosForApi(ids, query, options))
1763 ])
1764
1765 return {
1766 data: rows,
1767 total: count
1768 }
1769 }
1770
1771 private static loadCompleteVideosForApi (ids: number[], query: FindOptions, options: AvailableForListIDsOptions) {
1772 if (ids.length === 0) return []
1773
1774 const secondQuery: FindOptions = {
1775 offset: 0,
1776 limit: query.limit,
1777 attributes: query.attributes,
1778 order: [ // Keep original order
1779 Sequelize.literal(
1780 ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ')
1781 )
1782 ]
1783 }
1784
1785 const apiScope: (string | ScopeOptions)[] = []
1786
1787 if (options.user) {
1788 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1789 }
1790
1791 apiScope.push({
1792 method: [
1793 ScopeNames.FOR_API, {
1794 ids,
1795 withFiles: options.withFiles,
1796 videoPlaylistId: options.videoPlaylistId
1797 } as ForAPIOptions
1798 ]
1799 })
1800
1801 return VideoModel.scope(apiScope).findAll(secondQuery)
1802 }
1803
1804 private static isPrivacyForFederation (privacy: VideoPrivacy) {
1805 return privacy === VideoPrivacy.PUBLIC || privacy === VideoPrivacy.UNLISTED
1806 }
1807
1808 static getCategoryLabel (id: number) {
1809 return VIDEO_CATEGORIES[ id ] || 'Misc'
1810 }
1811
1812 static getLicenceLabel (id: number) {
1813 return VIDEO_LICENCES[ id ] || 'Unknown'
1814 }
1815
1816 static getLanguageLabel (id: string) {
1817 return VIDEO_LANGUAGES[ id ] || 'Unknown'
1818 }
1819
1820 static getPrivacyLabel (id: number) {
1821 return VIDEO_PRIVACIES[ id ] || 'Unknown'
1822 }
1823
1824 static getStateLabel (id: number) {
1825 return VIDEO_STATES[ id ] || 'Unknown'
1826 }
1827
1828 isBlacklisted () {
1829 return !!this.VideoBlacklist
1830 }
1831
1832 isBlocked () {
1833 return (this.VideoChannel.Account.Actor.Server && this.VideoChannel.Account.Actor.Server.isBlocked()) ||
1834 this.VideoChannel.Account.isBlocked()
1835 }
1836
1837 getQualityFileBy <T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1838 if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
1839 const file = fun(this.VideoFiles, file => file.resolution)
1840
1841 return Object.assign(file, { Video: this })
1842 }
1843
1844 // No webtorrent files, try with streaming playlist files
1845 if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
1846 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1847
1848 const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
1849 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1850 }
1851
1852 return undefined
1853 }
1854
1855 getMaxQualityFile <T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1856 return this.getQualityFileBy(maxBy)
1857 }
1858
1859 getMinQualityFile <T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1860 return this.getQualityFileBy(minBy)
1861 }
1862
1863 getWebTorrentFile <T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1864 if (Array.isArray(this.VideoFiles) === false) return undefined
1865
1866 const file = this.VideoFiles.find(f => f.resolution === resolution)
1867 if (!file) return undefined
1868
1869 return Object.assign(file, { Video: this })
1870 }
1871
1872 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) {
1873 thumbnail.videoId = this.id
1874
1875 const savedThumbnail = await thumbnail.save({ transaction })
1876
1877 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1878
1879 // Already have this thumbnail, skip
1880 if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
1881
1882 this.Thumbnails.push(savedThumbnail)
1883 }
1884
1885 generateThumbnailName () {
1886 return this.uuid + '.jpg'
1887 }
1888
1889 getMiniature () {
1890 if (Array.isArray(this.Thumbnails) === false) return undefined
1891
1892 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1893 }
1894
1895 generatePreviewName () {
1896 return this.uuid + '.jpg'
1897 }
1898
1899 getPreview () {
1900 if (Array.isArray(this.Thumbnails) === false) return undefined
1901
1902 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1903 }
1904
1905 isOwned () {
1906 return this.remote === false
1907 }
1908
1909 getWatchStaticPath () {
1910 return '/videos/watch/' + this.uuid
1911 }
1912
1913 getEmbedStaticPath () {
1914 return '/videos/embed/' + this.uuid
1915 }
1916
1917 getMiniatureStaticPath () {
1918 const thumbnail = this.getMiniature()
1919 if (!thumbnail) return null
1920
1921 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1922 }
1923
1924 getPreviewStaticPath () {
1925 const preview = this.getPreview()
1926 if (!preview) return null
1927
1928 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1929 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1930 }
1931
1932 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1933 return videoModelToFormattedJSON(this, options)
1934 }
1935
1936 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1937 return videoModelToFormattedDetailsJSON(this)
1938 }
1939
1940 getFormattedVideoFilesJSON (): VideoFile[] {
1941 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1942 return videoFilesModelToFormattedJSON(this, baseUrlHttp, baseUrlWs, this.VideoFiles)
1943 }
1944
1945 toActivityPubObject (this: MVideoAP): VideoTorrentObject {
1946 return videoModelToActivityPubObject(this)
1947 }
1948
1949 getTruncatedDescription () {
1950 if (!this.description) return null
1951
1952 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1953 return peertubeTruncate(this.description, { length: maxLength })
1954 }
1955
1956 getMaxQualityResolution () {
1957 const file = this.getMaxQualityFile()
1958 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1959 const originalFilePath = getVideoFilePath(videoOrPlaylist, file)
1960
1961 return getVideoFileResolution(originalFilePath)
1962 }
1963
1964 getDescriptionAPIPath () {
1965 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1966 }
1967
1968 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1969 if (!this.VideoStreamingPlaylists) return undefined
1970
1971 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1972 playlist.Video = this
1973
1974 return playlist
1975 }
1976
1977 setHLSPlaylist (playlist: MStreamingPlaylist) {
1978 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1979
1980 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1981 this.VideoStreamingPlaylists = toAdd
1982 return
1983 }
1984
1985 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1986 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1987 .concat(toAdd)
1988 }
1989
1990 removeFile (videoFile: MVideoFile, isRedundancy = false) {
1991 const filePath = getVideoFilePath(this, videoFile, isRedundancy)
1992 return remove(filePath)
1993 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
1994 }
1995
1996 removeTorrent (videoFile: MVideoFile) {
1997 const torrentPath = getTorrentFilePath(this, videoFile)
1998 return remove(torrentPath)
1999 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
2000 }
2001
2002 removeStreamingPlaylist (isRedundancy = false) {
2003 const directoryPath = getHLSDirectory(this, isRedundancy)
2004
2005 return remove(directoryPath)
2006 .catch(err => logger.warn('Cannot delete playlist directory %s.', directoryPath, { err }))
2007 }
2008
2009 isOutdated () {
2010 if (this.isOwned()) return false
2011
2012 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
2013 }
2014
2015 hasPrivacyForFederation () {
2016 return VideoModel.isPrivacyForFederation(this.privacy)
2017 }
2018
2019 isNewVideo (newPrivacy: VideoPrivacy) {
2020 return this.hasPrivacyForFederation() === false && VideoModel.isPrivacyForFederation(newPrivacy) === true
2021 }
2022
2023 setAsRefreshed () {
2024 this.changed('updatedAt', true)
2025
2026 return this.save()
2027 }
2028
2029 requiresAuth () {
2030 return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
2031 }
2032
2033 setPrivacy (newPrivacy: VideoPrivacy) {
2034 if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
2035 this.publishedAt = new Date()
2036 }
2037
2038 this.privacy = newPrivacy
2039 }
2040
2041 isConfidential () {
2042 return this.privacy === VideoPrivacy.PRIVATE ||
2043 this.privacy === VideoPrivacy.UNLISTED ||
2044 this.privacy === VideoPrivacy.INTERNAL
2045 }
2046
2047 async publishIfNeededAndSave (t: Transaction) {
2048 if (this.state !== VideoState.PUBLISHED) {
2049 this.state = VideoState.PUBLISHED
2050 this.publishedAt = new Date()
2051 await this.save({ transaction: t })
2052
2053 return true
2054 }
2055
2056 return false
2057 }
2058
2059 getBaseUrls () {
2060 if (this.isOwned()) {
2061 return {
2062 baseUrlHttp: WEBSERVER.URL,
2063 baseUrlWs: WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
2064 }
2065 }
2066
2067 return {
2068 baseUrlHttp: REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host,
2069 baseUrlWs: REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
2070 }
2071 }
2072
2073 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
2074 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
2075 }
2076
2077 getTorrentUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2078 return baseUrlHttp + STATIC_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
2079 }
2080
2081 getTorrentDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2082 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
2083 }
2084
2085 getVideoFileUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2086 return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile)
2087 }
2088
2089 getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2090 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile)
2091 }
2092
2093 getVideoFileDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2094 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + getVideoFilename(this, videoFile)
2095 }
2096
2097 getBandwidthBits (videoFile: MVideoFile) {
2098 return Math.ceil((videoFile.size * 8) / this.duration)
2099 }
2100 }