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