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