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