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