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