]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
eacffe1864156d84d84e0cc2ee817c892cd354ae
[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 MStreamingPlaylistFiles
141 } from '../../typings/models'
142 import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../typings/models/video/video-file'
143 import { MThumbnail } from '../../typings/models/video/thumbnail'
144 import { VideoFile } from '@shared/models/videos/video-file.model'
145 import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
146 import validator from 'validator'
147
148 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
149 const indexes: (ModelIndexesOptions & { where?: WhereOptions })[] = [
150 buildTrigramSearchIndex('video_name_trigram', 'name'),
151
152 { fields: [ 'createdAt' ] },
153 {
154 fields: [
155 { name: 'publishedAt', order: 'DESC' },
156 { name: 'id', order: 'ASC' }
157 ]
158 },
159 { fields: [ 'duration' ] },
160 { fields: [ 'views' ] },
161 { fields: [ 'channelId' ] },
162 {
163 fields: [ 'originallyPublishedAt' ],
164 where: {
165 originallyPublishedAt: {
166 [Op.ne]: null
167 }
168 }
169 },
170 {
171 fields: [ 'category' ], // We don't care videos with an unknown category
172 where: {
173 category: {
174 [Op.ne]: null
175 }
176 }
177 },
178 {
179 fields: [ 'licence' ], // We don't care videos with an unknown licence
180 where: {
181 licence: {
182 [Op.ne]: null
183 }
184 }
185 },
186 {
187 fields: [ 'language' ], // We don't care videos with an unknown language
188 where: {
189 language: {
190 [Op.ne]: null
191 }
192 }
193 },
194 {
195 fields: [ 'nsfw' ], // Most of the videos are not NSFW
196 where: {
197 nsfw: true
198 }
199 },
200 {
201 fields: [ 'remote' ], // Only index local videos
202 where: {
203 remote: false
204 }
205 },
206 {
207 fields: [ 'uuid' ],
208 unique: true
209 },
210 {
211 fields: [ 'url' ],
212 unique: true
213 }
214 ]
215
216 export enum ScopeNames {
217 AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
218 FOR_API = 'FOR_API',
219 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
220 WITH_TAGS = 'WITH_TAGS',
221 WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES',
222 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
223 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
224 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
225 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
226 WITH_USER_ID = 'WITH_USER_ID',
227 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
228 }
229
230 export type ForAPIOptions = {
231 ids?: number[]
232
233 videoPlaylistId?: number
234
235 withFiles?: boolean
236
237 withAccountBlockerIds?: number[]
238 }
239
240 export type AvailableForListIDsOptions = {
241 serverAccountId: number
242 followerActorId: number
243 includeLocalVideos: boolean
244
245 attributesType?: 'none' | 'id' | 'all'
246
247 filter?: VideoFilter
248 categoryOneOf?: number[]
249 nsfw?: boolean
250 licenceOneOf?: number[]
251 languageOneOf?: string[]
252 tagsOneOf?: string[]
253 tagsAllOf?: string[]
254
255 withFiles?: boolean
256
257 accountId?: number
258 videoChannelId?: number
259
260 videoPlaylistId?: number
261
262 trendingDays?: number
263 user?: MUserAccountId
264 historyOfUser?: MUserId
265
266 baseWhere?: WhereOptions[]
267 }
268
269 @Scopes(() => ({
270 [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
271 const query: FindOptions = {
272 include: [
273 {
274 model: VideoChannelModel.scope({
275 method: [
276 VideoChannelScopeNames.SUMMARY, {
277 withAccount: true,
278 withAccountBlockerIds: options.withAccountBlockerIds
279 } as SummaryOptions
280 ]
281 }),
282 required: true
283 },
284 {
285 attributes: [ 'type', 'filename' ],
286 model: ThumbnailModel,
287 required: false
288 }
289 ]
290 }
291
292 if (options.ids) {
293 query.where = {
294 id: {
295 [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken
296 }
297 }
298 }
299
300 if (options.withFiles === true) {
301 query.include.push({
302 model: VideoFileModel.unscoped(),
303 required: true
304 })
305 }
306
307 if (options.videoPlaylistId) {
308 query.include.push({
309 model: VideoPlaylistElementModel.unscoped(),
310 required: true,
311 where: {
312 videoPlaylistId: options.videoPlaylistId
313 }
314 })
315 }
316
317 return query
318 },
319 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
320 const whereAnd = options.baseWhere ? [].concat(options.baseWhere) : []
321
322 const query: FindOptions = {
323 raw: true,
324 include: []
325 }
326
327 const attributesType = options.attributesType || 'id'
328
329 if (attributesType === 'id') query.attributes = [ 'id' ]
330 else if (attributesType === 'none') query.attributes = [ ]
331
332 whereAnd.push({
333 id: {
334 [ Op.notIn ]: Sequelize.literal(
335 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
336 )
337 }
338 })
339
340 if (options.serverAccountId) {
341 whereAnd.push({
342 channelId: {
343 [ Op.notIn ]: Sequelize.literal(
344 '(' +
345 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
346 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
347 ')' +
348 ')'
349 )
350 }
351 })
352 }
353
354 // Only list public/published videos
355 if (!options.filter || options.filter !== 'all-local') {
356
357 const publishWhere = {
358 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
359 [ Op.or ]: [
360 {
361 state: VideoState.PUBLISHED
362 },
363 {
364 [ Op.and ]: {
365 state: VideoState.TO_TRANSCODE,
366 waitTranscoding: false
367 }
368 }
369 ]
370 }
371 whereAnd.push(publishWhere)
372
373 // List internal videos if the user is logged in
374 if (options.user) {
375 const privacyWhere = {
376 [Op.or]: [
377 {
378 privacy: VideoPrivacy.INTERNAL
379 },
380 {
381 privacy: VideoPrivacy.PUBLIC
382 }
383 ]
384 }
385
386 whereAnd.push(privacyWhere)
387 } else { // Or only public videos
388 const privacyWhere = { privacy: VideoPrivacy.PUBLIC }
389 whereAnd.push(privacyWhere)
390 }
391 }
392
393 if (options.videoPlaylistId) {
394 query.include.push({
395 attributes: [],
396 model: VideoPlaylistElementModel.unscoped(),
397 required: true,
398 where: {
399 videoPlaylistId: options.videoPlaylistId
400 }
401 })
402
403 query.subQuery = false
404 }
405
406 if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) {
407 whereAnd.push({
408 remote: false
409 })
410 }
411
412 if (options.accountId || options.videoChannelId) {
413 const videoChannelInclude: IncludeOptions = {
414 attributes: [],
415 model: VideoChannelModel.unscoped(),
416 required: true
417 }
418
419 if (options.videoChannelId) {
420 videoChannelInclude.where = {
421 id: options.videoChannelId
422 }
423 }
424
425 if (options.accountId) {
426 const accountInclude: IncludeOptions = {
427 attributes: [],
428 model: AccountModel.unscoped(),
429 required: true
430 }
431
432 accountInclude.where = { id: options.accountId }
433 videoChannelInclude.include = [ accountInclude ]
434 }
435
436 query.include.push(videoChannelInclude)
437 }
438
439 if (options.followerActorId) {
440 let localVideosReq: WhereOptions = {}
441 if (options.includeLocalVideos === true) {
442 localVideosReq = { remote: false }
443 }
444
445 // Force actorId to be a number to avoid SQL injections
446 const actorIdNumber = parseInt(options.followerActorId.toString(), 10)
447 whereAnd.push({
448 [Op.or]: [
449 {
450 id: {
451 [ Op.in ]: Sequelize.literal(
452 '(' +
453 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
454 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
455 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
456 ')'
457 )
458 }
459 },
460 {
461 id: {
462 [ Op.in ]: Sequelize.literal(
463 '(' +
464 'SELECT "video"."id" AS "id" FROM "video" ' +
465 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
466 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
467 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
468 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
469 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
470 ')'
471 )
472 }
473 },
474 localVideosReq
475 ]
476 })
477 }
478
479 if (options.withFiles === true) {
480 whereAnd.push({
481 id: {
482 [ Op.in ]: Sequelize.literal(
483 '(SELECT "videoId" FROM "videoFile")'
484 )
485 }
486 })
487 }
488
489 // FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN()
490 if (options.tagsAllOf || options.tagsOneOf) {
491 if (options.tagsOneOf) {
492 const tagsOneOfLower = options.tagsOneOf.map(t => t.toLowerCase())
493
494 whereAnd.push({
495 id: {
496 [ Op.in ]: Sequelize.literal(
497 '(' +
498 'SELECT "videoId" FROM "videoTag" ' +
499 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
500 'WHERE lower("tag"."name") IN (' + createSafeIn(VideoModel, tagsOneOfLower) + ')' +
501 ')'
502 )
503 }
504 })
505 }
506
507 if (options.tagsAllOf) {
508 const tagsAllOfLower = options.tagsAllOf.map(t => t.toLowerCase())
509
510 whereAnd.push({
511 id: {
512 [ Op.in ]: Sequelize.literal(
513 '(' +
514 'SELECT "videoId" FROM "videoTag" ' +
515 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
516 'WHERE lower("tag"."name") IN (' + createSafeIn(VideoModel, tagsAllOfLower) + ')' +
517 'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length +
518 ')'
519 )
520 }
521 })
522 }
523 }
524
525 if (options.nsfw === true || options.nsfw === false) {
526 whereAnd.push({ nsfw: options.nsfw })
527 }
528
529 if (options.categoryOneOf) {
530 whereAnd.push({
531 category: {
532 [ Op.or ]: options.categoryOneOf
533 }
534 })
535 }
536
537 if (options.licenceOneOf) {
538 whereAnd.push({
539 licence: {
540 [ Op.or ]: options.licenceOneOf
541 }
542 })
543 }
544
545 if (options.languageOneOf) {
546 let videoLanguages = options.languageOneOf
547 if (options.languageOneOf.find(l => l === '_unknown')) {
548 videoLanguages = videoLanguages.concat([ null ])
549 }
550
551 whereAnd.push({
552 [Op.or]: [
553 {
554 language: {
555 [ Op.or ]: videoLanguages
556 }
557 },
558 {
559 id: {
560 [ Op.in ]: Sequelize.literal(
561 '(' +
562 'SELECT "videoId" FROM "videoCaption" ' +
563 'WHERE "language" IN (' + createSafeIn(VideoModel, options.languageOneOf) + ') ' +
564 ')'
565 )
566 }
567 }
568 ]
569 })
570 }
571
572 if (options.trendingDays) {
573 query.include.push(VideoModel.buildTrendingQuery(options.trendingDays))
574
575 query.subQuery = false
576 }
577
578 if (options.historyOfUser) {
579 query.include.push({
580 model: UserVideoHistoryModel,
581 required: true,
582 where: {
583 userId: options.historyOfUser.id
584 }
585 })
586
587 // Even if the relation is n:m, we know that a user only have 0..1 video history
588 // So we won't have multiple rows for the same video
589 // Without this, we would not be able to sort on "updatedAt" column of UserVideoHistoryModel
590 query.subQuery = false
591 }
592
593 query.where = {
594 [ Op.and ]: whereAnd
595 }
596
597 return query
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')
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 if (!Array.isArray(instance.VideoStreamingPlaylists)) {
1076 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists')
1077 }
1078
1079 for (const p of instance.VideoStreamingPlaylists) {
1080 tasks.push(instance.removeStreamingPlaylistFiles(p))
1081 }
1082 }
1083
1084 // Do not wait video deletion because we could be in a transaction
1085 Promise.all(tasks)
1086 .catch(err => {
1087 logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
1088 })
1089
1090 return undefined
1091 }
1092
1093 static listLocal (): Bluebird<MVideoWithAllFiles[]> {
1094 const query = {
1095 where: {
1096 remote: false
1097 }
1098 }
1099
1100 return VideoModel.scope([
1101 ScopeNames.WITH_WEBTORRENT_FILES,
1102 ScopeNames.WITH_STREAMING_PLAYLISTS,
1103 ScopeNames.WITH_THUMBNAILS
1104 ]).findAll(query)
1105 }
1106
1107 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
1108 function getRawQuery (select: string) {
1109 const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
1110 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
1111 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
1112 'WHERE "Account"."actorId" = ' + actorId
1113 const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
1114 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
1115 'WHERE "VideoShare"."actorId" = ' + actorId
1116
1117 return `(${queryVideo}) UNION (${queryVideoShare})`
1118 }
1119
1120 const rawQuery = getRawQuery('"Video"."id"')
1121 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
1122
1123 const query = {
1124 distinct: true,
1125 offset: start,
1126 limit: count,
1127 order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings
1128 where: {
1129 id: {
1130 [ Op.in ]: Sequelize.literal('(' + rawQuery + ')')
1131 },
1132 [ Op.or ]: [
1133 { privacy: VideoPrivacy.PUBLIC },
1134 { privacy: VideoPrivacy.UNLISTED }
1135 ]
1136 },
1137 include: [
1138 {
1139 attributes: [ 'language' ],
1140 model: VideoCaptionModel.unscoped(),
1141 required: false
1142 },
1143 {
1144 attributes: [ 'id', 'url' ],
1145 model: VideoShareModel.unscoped(),
1146 required: false,
1147 // We only want videos shared by this actor
1148 where: {
1149 [ Op.and ]: [
1150 {
1151 id: {
1152 [ Op.not ]: null
1153 }
1154 },
1155 {
1156 actorId
1157 }
1158 ]
1159 },
1160 include: [
1161 {
1162 attributes: [ 'id', 'url' ],
1163 model: ActorModel.unscoped()
1164 }
1165 ]
1166 },
1167 {
1168 model: VideoChannelModel.unscoped(),
1169 required: true,
1170 include: [
1171 {
1172 attributes: [ 'name' ],
1173 model: AccountModel.unscoped(),
1174 required: true,
1175 include: [
1176 {
1177 attributes: [ 'id', 'url', 'followersUrl' ],
1178 model: ActorModel.unscoped(),
1179 required: true
1180 }
1181 ]
1182 },
1183 {
1184 attributes: [ 'id', 'url', 'followersUrl' ],
1185 model: ActorModel.unscoped(),
1186 required: true
1187 }
1188 ]
1189 },
1190 VideoFileModel,
1191 TagModel
1192 ]
1193 }
1194
1195 return Bluebird.all([
1196 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
1197 VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
1198 ]).then(([ rows, totals ]) => {
1199 // totals: totalVideos + totalVideoShares
1200 let totalVideos = 0
1201 let totalVideoShares = 0
1202 if (totals[ 0 ]) totalVideos = parseInt(totals[ 0 ].total, 10)
1203 if (totals[ 1 ]) totalVideoShares = parseInt(totals[ 1 ].total, 10)
1204
1205 const total = totalVideos + totalVideoShares
1206 return {
1207 data: rows,
1208 total: total
1209 }
1210 })
1211 }
1212
1213 static listUserVideosForApi (
1214 accountId: number,
1215 start: number,
1216 count: number,
1217 sort: string,
1218 search?: string
1219 ) {
1220 function buildBaseQuery (): FindOptions {
1221 let baseQuery = {
1222 offset: start,
1223 limit: count,
1224 order: getVideoSort(sort),
1225 include: [
1226 {
1227 model: VideoChannelModel,
1228 required: true,
1229 include: [
1230 {
1231 model: AccountModel,
1232 where: {
1233 id: accountId
1234 },
1235 required: true
1236 }
1237 ]
1238 }
1239 ]
1240 }
1241
1242 if (search) {
1243 baseQuery = Object.assign(baseQuery, {
1244 where: {
1245 name: {
1246 [ Op.iLike ]: '%' + search + '%'
1247 }
1248 }
1249 })
1250 }
1251
1252 return baseQuery
1253 }
1254
1255 const countQuery = buildBaseQuery()
1256 const findQuery = buildBaseQuery()
1257
1258 const findScopes: (string | ScopeOptions)[] = [
1259 ScopeNames.WITH_SCHEDULED_UPDATE,
1260 ScopeNames.WITH_BLACKLISTED,
1261 ScopeNames.WITH_THUMBNAILS
1262 ]
1263
1264 return Promise.all([
1265 VideoModel.count(countQuery),
1266 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1267 ]).then(([ count, rows ]) => {
1268 return {
1269 data: rows,
1270 total: count
1271 }
1272 })
1273 }
1274
1275 static async listForApi (options: {
1276 start: number,
1277 count: number,
1278 sort: string,
1279 nsfw: boolean,
1280 includeLocalVideos: boolean,
1281 withFiles: boolean,
1282 categoryOneOf?: number[],
1283 licenceOneOf?: number[],
1284 languageOneOf?: string[],
1285 tagsOneOf?: string[],
1286 tagsAllOf?: string[],
1287 filter?: VideoFilter,
1288 accountId?: number,
1289 videoChannelId?: number,
1290 followerActorId?: number
1291 videoPlaylistId?: number,
1292 trendingDays?: number,
1293 user?: MUserAccountId,
1294 historyOfUser?: MUserId,
1295 countVideos?: boolean
1296 }) {
1297 if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1298 throw new Error('Try to filter all-local but no user has not the see all videos right')
1299 }
1300
1301 const query: FindOptions & { where?: null } = {
1302 offset: options.start,
1303 limit: options.count,
1304 order: getVideoSort(options.sort)
1305 }
1306
1307 let trendingDays: number
1308 if (options.sort.endsWith('trending')) {
1309 trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1310
1311 query.group = 'VideoModel.id'
1312 }
1313
1314 const serverActor = await getServerActor()
1315
1316 // followerActorId === null has a meaning, so just check undefined
1317 const followerActorId = options.followerActorId !== undefined ? options.followerActorId : serverActor.id
1318
1319 const queryOptions = {
1320 followerActorId,
1321 serverAccountId: serverActor.Account.id,
1322 nsfw: options.nsfw,
1323 categoryOneOf: options.categoryOneOf,
1324 licenceOneOf: options.licenceOneOf,
1325 languageOneOf: options.languageOneOf,
1326 tagsOneOf: options.tagsOneOf,
1327 tagsAllOf: options.tagsAllOf,
1328 filter: options.filter,
1329 withFiles: options.withFiles,
1330 accountId: options.accountId,
1331 videoChannelId: options.videoChannelId,
1332 videoPlaylistId: options.videoPlaylistId,
1333 includeLocalVideos: options.includeLocalVideos,
1334 user: options.user,
1335 historyOfUser: options.historyOfUser,
1336 trendingDays
1337 }
1338
1339 return VideoModel.getAvailableForApi(query, queryOptions, options.countVideos)
1340 }
1341
1342 static async searchAndPopulateAccountAndServer (options: {
1343 includeLocalVideos: boolean
1344 search?: string
1345 start?: number
1346 count?: number
1347 sort?: string
1348 startDate?: string // ISO 8601
1349 endDate?: string // ISO 8601
1350 originallyPublishedStartDate?: string
1351 originallyPublishedEndDate?: string
1352 nsfw?: boolean
1353 categoryOneOf?: number[]
1354 licenceOneOf?: number[]
1355 languageOneOf?: string[]
1356 tagsOneOf?: string[]
1357 tagsAllOf?: string[]
1358 durationMin?: number // seconds
1359 durationMax?: number // seconds
1360 user?: MUserAccountId,
1361 filter?: VideoFilter
1362 }) {
1363 const whereAnd = []
1364
1365 if (options.startDate || options.endDate) {
1366 const publishedAtRange = {}
1367
1368 if (options.startDate) publishedAtRange[ Op.gte ] = options.startDate
1369 if (options.endDate) publishedAtRange[ Op.lte ] = options.endDate
1370
1371 whereAnd.push({ publishedAt: publishedAtRange })
1372 }
1373
1374 if (options.originallyPublishedStartDate || options.originallyPublishedEndDate) {
1375 const originallyPublishedAtRange = {}
1376
1377 if (options.originallyPublishedStartDate) originallyPublishedAtRange[ Op.gte ] = options.originallyPublishedStartDate
1378 if (options.originallyPublishedEndDate) originallyPublishedAtRange[ Op.lte ] = options.originallyPublishedEndDate
1379
1380 whereAnd.push({ originallyPublishedAt: originallyPublishedAtRange })
1381 }
1382
1383 if (options.durationMin || options.durationMax) {
1384 const durationRange = {}
1385
1386 if (options.durationMin) durationRange[ Op.gte ] = options.durationMin
1387 if (options.durationMax) durationRange[ Op.lte ] = options.durationMax
1388
1389 whereAnd.push({ duration: durationRange })
1390 }
1391
1392 const attributesInclude = []
1393 const escapedSearch = VideoModel.sequelize.escape(options.search)
1394 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
1395 if (options.search) {
1396 const trigramSearch = {
1397 id: {
1398 [ Op.in ]: Sequelize.literal(
1399 '(' +
1400 'SELECT "video"."id" FROM "video" ' +
1401 'WHERE ' +
1402 'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
1403 'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
1404 'UNION ALL ' +
1405 'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
1406 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
1407 'WHERE lower("tag"."name") = lower(' + escapedSearch + ')' +
1408 ')'
1409 )
1410 }
1411 }
1412
1413 if (validator.isUUID(options.search)) {
1414 whereAnd.push({
1415 [Op.or]: [
1416 trigramSearch,
1417 {
1418 uuid: options.search
1419 }
1420 ]
1421 })
1422 } else {
1423 whereAnd.push(trigramSearch)
1424 }
1425
1426 attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search))
1427 }
1428
1429 // Cannot search on similarity if we don't have a search
1430 if (!options.search) {
1431 attributesInclude.push(
1432 Sequelize.literal('0 as similarity')
1433 )
1434 }
1435
1436 const query = {
1437 attributes: {
1438 include: attributesInclude
1439 },
1440 offset: options.start,
1441 limit: options.count,
1442 order: getVideoSort(options.sort)
1443 }
1444
1445 const serverActor = await getServerActor()
1446 const queryOptions = {
1447 followerActorId: serverActor.id,
1448 serverAccountId: serverActor.Account.id,
1449 includeLocalVideos: options.includeLocalVideos,
1450 nsfw: options.nsfw,
1451 categoryOneOf: options.categoryOneOf,
1452 licenceOneOf: options.licenceOneOf,
1453 languageOneOf: options.languageOneOf,
1454 tagsOneOf: options.tagsOneOf,
1455 tagsAllOf: options.tagsAllOf,
1456 user: options.user,
1457 filter: options.filter,
1458 baseWhere: whereAnd
1459 }
1460
1461 return VideoModel.getAvailableForApi(query, queryOptions)
1462 }
1463
1464 static load (id: number | string, t?: Transaction): Bluebird<MVideoThumbnail> {
1465 const where = buildWhereIdOrUUID(id)
1466 const options = {
1467 where,
1468 transaction: t
1469 }
1470
1471 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1472 }
1473
1474 static loadWithBlacklist (id: number | string, t?: Transaction): Bluebird<MVideoThumbnailBlacklist> {
1475 const where = buildWhereIdOrUUID(id)
1476 const options = {
1477 where,
1478 transaction: t
1479 }
1480
1481 return VideoModel.scope([
1482 ScopeNames.WITH_THUMBNAILS,
1483 ScopeNames.WITH_BLACKLISTED
1484 ]).findOne(options)
1485 }
1486
1487 static loadWithRights (id: number | string, t?: Transaction): Bluebird<MVideoWithRights> {
1488 const where = buildWhereIdOrUUID(id)
1489 const options = {
1490 where,
1491 transaction: t
1492 }
1493
1494 return VideoModel.scope([
1495 ScopeNames.WITH_BLACKLISTED,
1496 ScopeNames.WITH_USER_ID,
1497 ScopeNames.WITH_THUMBNAILS
1498 ]).findOne(options)
1499 }
1500
1501 static loadOnlyId (id: number | string, t?: Transaction): Bluebird<MVideoIdThumbnail> {
1502 const where = buildWhereIdOrUUID(id)
1503
1504 const options = {
1505 attributes: [ 'id' ],
1506 where,
1507 transaction: t
1508 }
1509
1510 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1511 }
1512
1513 static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Bluebird<MVideoWithAllFiles> {
1514 const where = buildWhereIdOrUUID(id)
1515
1516 const query = {
1517 where,
1518 transaction: t,
1519 logging
1520 }
1521
1522 return VideoModel.scope([
1523 ScopeNames.WITH_WEBTORRENT_FILES,
1524 ScopeNames.WITH_STREAMING_PLAYLISTS,
1525 ScopeNames.WITH_THUMBNAILS
1526 ]).findOne(query)
1527 }
1528
1529 static loadByUUID (uuid: string): Bluebird<MVideoThumbnail> {
1530 const options = {
1531 where: {
1532 uuid
1533 }
1534 }
1535
1536 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1537 }
1538
1539 static loadByUrl (url: string, transaction?: Transaction): Bluebird<MVideoThumbnail> {
1540 const query: FindOptions = {
1541 where: {
1542 url
1543 },
1544 transaction
1545 }
1546
1547 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1548 }
1549
1550 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Bluebird<MVideoAccountLightBlacklistAllFiles> {
1551 const query: FindOptions = {
1552 where: {
1553 url
1554 },
1555 transaction
1556 }
1557
1558 return VideoModel.scope([
1559 ScopeNames.WITH_ACCOUNT_DETAILS,
1560 ScopeNames.WITH_WEBTORRENT_FILES,
1561 ScopeNames.WITH_STREAMING_PLAYLISTS,
1562 ScopeNames.WITH_THUMBNAILS,
1563 ScopeNames.WITH_BLACKLISTED
1564 ]).findOne(query)
1565 }
1566
1567 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Bluebird<MVideoFullLight> {
1568 const where = buildWhereIdOrUUID(id)
1569
1570 const options = {
1571 order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
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_WEBTORRENT_FILES,
1582 ScopeNames.WITH_STREAMING_PLAYLISTS,
1583 ScopeNames.WITH_THUMBNAILS
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 loadForGetAPI (parameters: {
1596 id: number | string,
1597 t?: Transaction,
1598 userId?: number
1599 }): Bluebird<MVideoDetails> {
1600 const { id, t, userId } = parameters
1601 const where = buildWhereIdOrUUID(id)
1602
1603 const options = {
1604 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1605 where,
1606 transaction: t
1607 }
1608
1609 const scopes: (string | ScopeOptions)[] = [
1610 ScopeNames.WITH_TAGS,
1611 ScopeNames.WITH_BLACKLISTED,
1612 ScopeNames.WITH_ACCOUNT_DETAILS,
1613 ScopeNames.WITH_SCHEDULED_UPDATE,
1614 ScopeNames.WITH_THUMBNAILS,
1615 { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] },
1616 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1617 ]
1618
1619 if (userId) {
1620 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1621 }
1622
1623 return VideoModel
1624 .scope(scopes)
1625 .findOne(options)
1626 }
1627
1628 static async getStats () {
1629 const totalLocalVideos = await VideoModel.count({
1630 where: {
1631 remote: false
1632 }
1633 })
1634 const totalVideos = await VideoModel.count()
1635
1636 let totalLocalVideoViews = await VideoModel.sum('views', {
1637 where: {
1638 remote: false
1639 }
1640 })
1641 // Sequelize could return null...
1642 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1643
1644 return {
1645 totalLocalVideos,
1646 totalLocalVideoViews,
1647 totalVideos
1648 }
1649 }
1650
1651 static incrementViews (id: number, views: number) {
1652 return VideoModel.increment('views', {
1653 by: views,
1654 where: {
1655 id
1656 }
1657 })
1658 }
1659
1660 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1661 // Instances only share videos
1662 const query = 'SELECT 1 FROM "videoShare" ' +
1663 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1664 'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' +
1665 'LIMIT 1'
1666
1667 const options = {
1668 type: QueryTypes.SELECT as QueryTypes.SELECT,
1669 bind: { followerActorId, videoId },
1670 raw: true
1671 }
1672
1673 return VideoModel.sequelize.query(query, options)
1674 .then(results => results.length === 1)
1675 }
1676
1677 static bulkUpdateSupportField (videoChannel: MChannel, t: Transaction) {
1678 const options = {
1679 where: {
1680 channelId: videoChannel.id
1681 },
1682 transaction: t
1683 }
1684
1685 return VideoModel.update({ support: videoChannel.support }, options)
1686 }
1687
1688 static getAllIdsFromChannel (videoChannel: MChannelId): Bluebird<number[]> {
1689 const query = {
1690 attributes: [ 'id' ],
1691 where: {
1692 channelId: videoChannel.id
1693 }
1694 }
1695
1696 return VideoModel.findAll(query)
1697 .then(videos => videos.map(v => v.id))
1698 }
1699
1700 // threshold corresponds to how many video the field should have to be returned
1701 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1702 const serverActor = await getServerActor()
1703 const followerActorId = serverActor.id
1704
1705 const scopeOptions: AvailableForListIDsOptions = {
1706 serverAccountId: serverActor.Account.id,
1707 followerActorId,
1708 includeLocalVideos: true,
1709 attributesType: 'none' // Don't break aggregation
1710 }
1711
1712 const query: FindOptions = {
1713 attributes: [ field ],
1714 limit: count,
1715 group: field,
1716 having: Sequelize.where(
1717 Sequelize.fn('COUNT', Sequelize.col(field)), { [ Op.gte ]: threshold }
1718 ),
1719 order: [ (this.sequelize as any).random() ]
1720 }
1721
1722 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
1723 .findAll(query)
1724 .then(rows => rows.map(r => r[ field ]))
1725 }
1726
1727 static buildTrendingQuery (trendingDays: number) {
1728 return {
1729 attributes: [],
1730 subQuery: false,
1731 model: VideoViewModel,
1732 required: false,
1733 where: {
1734 startDate: {
1735 [ Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1736 }
1737 }
1738 }
1739 }
1740
1741 private static async getAvailableForApi (
1742 query: FindOptions & { where?: null }, // Forbid where field in query
1743 options: AvailableForListIDsOptions,
1744 countVideos = true
1745 ) {
1746 const idsScope: ScopeOptions = {
1747 method: [
1748 ScopeNames.AVAILABLE_FOR_LIST_IDS, options
1749 ]
1750 }
1751
1752 // Remove trending sort on count, because it uses a group by
1753 const countOptions = Object.assign({}, options, { trendingDays: undefined })
1754 const countQuery: CountOptions = Object.assign({}, query, { attributes: undefined, group: undefined })
1755 const countScope: ScopeOptions = {
1756 method: [
1757 ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions
1758 ]
1759 }
1760
1761 const [ count, rows ] = await Promise.all([
1762 countVideos
1763 ? VideoModel.scope(countScope).count(countQuery)
1764 : Promise.resolve<number>(undefined),
1765
1766 VideoModel.scope(idsScope)
1767 .findAll(Object.assign({}, query, { raw: true }))
1768 .then(rows => rows.map(r => r.id))
1769 .then(ids => VideoModel.loadCompleteVideosForApi(ids, query, options))
1770 ])
1771
1772 return {
1773 data: rows,
1774 total: count
1775 }
1776 }
1777
1778 private static loadCompleteVideosForApi (ids: number[], query: FindOptions, options: AvailableForListIDsOptions) {
1779 if (ids.length === 0) return []
1780
1781 const secondQuery: FindOptions = {
1782 offset: 0,
1783 limit: query.limit,
1784 attributes: query.attributes,
1785 order: [ // Keep original order
1786 Sequelize.literal(
1787 ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ')
1788 )
1789 ]
1790 }
1791
1792 const apiScope: (string | ScopeOptions)[] = []
1793
1794 if (options.user) {
1795 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1796 }
1797
1798 apiScope.push({
1799 method: [
1800 ScopeNames.FOR_API, {
1801 ids,
1802 withFiles: options.withFiles,
1803 videoPlaylistId: options.videoPlaylistId
1804 } as ForAPIOptions
1805 ]
1806 })
1807
1808 return VideoModel.scope(apiScope).findAll(secondQuery)
1809 }
1810
1811 private static isPrivacyForFederation (privacy: VideoPrivacy) {
1812 const castedPrivacy = parseInt(privacy + '', 10)
1813
1814 return castedPrivacy === VideoPrivacy.PUBLIC || castedPrivacy === VideoPrivacy.UNLISTED
1815 }
1816
1817 static getCategoryLabel (id: number) {
1818 return VIDEO_CATEGORIES[ id ] || 'Misc'
1819 }
1820
1821 static getLicenceLabel (id: number) {
1822 return VIDEO_LICENCES[ id ] || 'Unknown'
1823 }
1824
1825 static getLanguageLabel (id: string) {
1826 return VIDEO_LANGUAGES[ id ] || 'Unknown'
1827 }
1828
1829 static getPrivacyLabel (id: number) {
1830 return VIDEO_PRIVACIES[ id ] || 'Unknown'
1831 }
1832
1833 static getStateLabel (id: number) {
1834 return VIDEO_STATES[ id ] || 'Unknown'
1835 }
1836
1837 isBlacklisted () {
1838 return !!this.VideoBlacklist
1839 }
1840
1841 isBlocked () {
1842 return (this.VideoChannel.Account.Actor.Server && this.VideoChannel.Account.Actor.Server.isBlocked()) ||
1843 this.VideoChannel.Account.isBlocked()
1844 }
1845
1846 getQualityFileBy <T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1847 if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
1848 const file = fun(this.VideoFiles, file => file.resolution)
1849
1850 return Object.assign(file, { Video: this })
1851 }
1852
1853 // No webtorrent files, try with streaming playlist files
1854 if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
1855 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1856
1857 const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
1858 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1859 }
1860
1861 return undefined
1862 }
1863
1864 getMaxQualityFile <T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1865 return this.getQualityFileBy(maxBy)
1866 }
1867
1868 getMinQualityFile <T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1869 return this.getQualityFileBy(minBy)
1870 }
1871
1872 getWebTorrentFile <T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1873 if (Array.isArray(this.VideoFiles) === false) return undefined
1874
1875 const file = this.VideoFiles.find(f => f.resolution === resolution)
1876 if (!file) return undefined
1877
1878 return Object.assign(file, { Video: this })
1879 }
1880
1881 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) {
1882 thumbnail.videoId = this.id
1883
1884 const savedThumbnail = await thumbnail.save({ transaction })
1885
1886 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1887
1888 // Already have this thumbnail, skip
1889 if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
1890
1891 this.Thumbnails.push(savedThumbnail)
1892 }
1893
1894 generateThumbnailName () {
1895 return this.uuid + '.jpg'
1896 }
1897
1898 getMiniature () {
1899 if (Array.isArray(this.Thumbnails) === false) return undefined
1900
1901 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1902 }
1903
1904 generatePreviewName () {
1905 return this.uuid + '.jpg'
1906 }
1907
1908 getPreview () {
1909 if (Array.isArray(this.Thumbnails) === false) return undefined
1910
1911 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1912 }
1913
1914 isOwned () {
1915 return this.remote === false
1916 }
1917
1918 getWatchStaticPath () {
1919 return '/videos/watch/' + this.uuid
1920 }
1921
1922 getEmbedStaticPath () {
1923 return '/videos/embed/' + this.uuid
1924 }
1925
1926 getMiniatureStaticPath () {
1927 const thumbnail = this.getMiniature()
1928 if (!thumbnail) return null
1929
1930 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1931 }
1932
1933 getPreviewStaticPath () {
1934 const preview = this.getPreview()
1935 if (!preview) return null
1936
1937 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1938 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1939 }
1940
1941 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1942 return videoModelToFormattedJSON(this, options)
1943 }
1944
1945 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1946 return videoModelToFormattedDetailsJSON(this)
1947 }
1948
1949 getFormattedVideoFilesJSON (): VideoFile[] {
1950 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1951 return videoFilesModelToFormattedJSON(this, baseUrlHttp, baseUrlWs, this.VideoFiles)
1952 }
1953
1954 toActivityPubObject (this: MVideoAP): VideoTorrentObject {
1955 return videoModelToActivityPubObject(this)
1956 }
1957
1958 getTruncatedDescription () {
1959 if (!this.description) return null
1960
1961 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1962 return peertubeTruncate(this.description, { length: maxLength })
1963 }
1964
1965 getMaxQualityResolution () {
1966 const file = this.getMaxQualityFile()
1967 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1968 const originalFilePath = getVideoFilePath(videoOrPlaylist, file)
1969
1970 return getVideoFileResolution(originalFilePath)
1971 }
1972
1973 getDescriptionAPIPath () {
1974 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1975 }
1976
1977 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1978 if (!this.VideoStreamingPlaylists) return undefined
1979
1980 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1981 playlist.Video = this
1982
1983 return playlist
1984 }
1985
1986 setHLSPlaylist (playlist: MStreamingPlaylist) {
1987 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1988
1989 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1990 this.VideoStreamingPlaylists = toAdd
1991 return
1992 }
1993
1994 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1995 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1996 .concat(toAdd)
1997 }
1998
1999 removeFile (videoFile: MVideoFile, isRedundancy = false) {
2000 const filePath = getVideoFilePath(this, videoFile, isRedundancy)
2001 return remove(filePath)
2002 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
2003 }
2004
2005 removeTorrent (videoFile: MVideoFile) {
2006 const torrentPath = getTorrentFilePath(this, videoFile)
2007 return remove(torrentPath)
2008 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
2009 }
2010
2011 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
2012 const directoryPath = getHLSDirectory(this, isRedundancy)
2013
2014 await remove(directoryPath)
2015
2016 if (isRedundancy !== true) {
2017 let streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
2018 streamingPlaylistWithFiles.Video = this
2019
2020 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
2021 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
2022 }
2023
2024 // Remove physical files and torrents
2025 await Promise.all(
2026 streamingPlaylistWithFiles.VideoFiles.map(file => streamingPlaylistWithFiles.removeTorrent(file))
2027 )
2028 }
2029 }
2030
2031 isOutdated () {
2032 if (this.isOwned()) return false
2033
2034 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
2035 }
2036
2037 hasPrivacyForFederation () {
2038 return VideoModel.isPrivacyForFederation(this.privacy)
2039 }
2040
2041 isNewVideo (newPrivacy: VideoPrivacy) {
2042 return this.hasPrivacyForFederation() === false && VideoModel.isPrivacyForFederation(newPrivacy) === true
2043 }
2044
2045 setAsRefreshed () {
2046 this.changed('updatedAt', true)
2047
2048 return this.save()
2049 }
2050
2051 requiresAuth () {
2052 return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
2053 }
2054
2055 setPrivacy (newPrivacy: VideoPrivacy) {
2056 if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
2057 this.publishedAt = new Date()
2058 }
2059
2060 this.privacy = newPrivacy
2061 }
2062
2063 isConfidential () {
2064 return this.privacy === VideoPrivacy.PRIVATE ||
2065 this.privacy === VideoPrivacy.UNLISTED ||
2066 this.privacy === VideoPrivacy.INTERNAL
2067 }
2068
2069 async publishIfNeededAndSave (t: Transaction) {
2070 if (this.state !== VideoState.PUBLISHED) {
2071 this.state = VideoState.PUBLISHED
2072 this.publishedAt = new Date()
2073 await this.save({ transaction: t })
2074
2075 return true
2076 }
2077
2078 return false
2079 }
2080
2081 getBaseUrls () {
2082 if (this.isOwned()) {
2083 return {
2084 baseUrlHttp: WEBSERVER.URL,
2085 baseUrlWs: WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
2086 }
2087 }
2088
2089 return {
2090 baseUrlHttp: REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host,
2091 baseUrlWs: REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
2092 }
2093 }
2094
2095 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
2096 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
2097 }
2098
2099 getTorrentUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2100 return baseUrlHttp + STATIC_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
2101 }
2102
2103 getTorrentDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2104 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
2105 }
2106
2107 getVideoFileUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2108 return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile)
2109 }
2110
2111 getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2112 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile)
2113 }
2114
2115 getVideoFileDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2116 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + getVideoFilename(this, videoFile)
2117 }
2118
2119 getBandwidthBits (videoFile: MVideoFile) {
2120 return Math.ceil((videoFile.size * 8) / this.duration)
2121 }
2122 }