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