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