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