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