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