aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
Diffstat (limited to 'server/models')
-rw-r--r--server/models/account/account.ts8
-rw-r--r--server/models/actor/actor-follow.ts36
-rw-r--r--server/models/redundancy/video-redundancy.ts4
-rw-r--r--server/models/shared/index.ts2
-rw-r--r--server/models/shared/query.ts17
-rw-r--r--server/models/shared/update.ts18
-rw-r--r--server/models/user/user-notification.ts3
-rw-r--r--server/models/video/formatter/video-format-utils.ts8
-rw-r--r--server/models/video/sql/shared/video-tables.ts3
-rw-r--r--server/models/video/sql/videos-id-list-query-builder.ts35
-rw-r--r--server/models/video/video-channel.ts165
-rw-r--r--server/models/video/video-file.ts45
-rw-r--r--server/models/video/video-playlist.ts64
-rw-r--r--server/models/video/video-streaming-playlist.ts85
-rw-r--r--server/models/video/video.ts131
15 files changed, 402 insertions, 222 deletions
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 665ecd595..37194a119 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -52,6 +52,7 @@ export enum ScopeNames {
52export type SummaryOptions = { 52export type SummaryOptions = {
53 actorRequired?: boolean // Default: true 53 actorRequired?: boolean // Default: true
54 whereActor?: WhereOptions 54 whereActor?: WhereOptions
55 whereServer?: WhereOptions
55 withAccountBlockerIds?: number[] 56 withAccountBlockerIds?: number[]
56} 57}
57 58
@@ -65,12 +66,11 @@ export type SummaryOptions = {
65})) 66}))
66@Scopes(() => ({ 67@Scopes(() => ({
67 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { 68 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
68 const whereActor = options.whereActor || undefined
69
70 const serverInclude: IncludeOptions = { 69 const serverInclude: IncludeOptions = {
71 attributes: [ 'host' ], 70 attributes: [ 'host' ],
72 model: ServerModel.unscoped(), 71 model: ServerModel.unscoped(),
73 required: false 72 required: !!options.whereServer,
73 where: options.whereServer
74 } 74 }
75 75
76 const queryInclude: Includeable[] = [ 76 const queryInclude: Includeable[] = [
@@ -78,7 +78,7 @@ export type SummaryOptions = {
78 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], 78 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
79 model: ActorModel.unscoped(), 79 model: ActorModel.unscoped(),
80 required: options.actorRequired ?? true, 80 required: options.actorRequired ?? true,
81 where: whereActor, 81 where: options.whereActor,
82 include: [ 82 include: [
83 serverInclude, 83 serverInclude,
84 84
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
index 3a09e51d6..283856d3f 100644
--- a/server/models/actor/actor-follow.ts
+++ b/server/models/actor/actor-follow.ts
@@ -20,7 +20,6 @@ import {
20} from 'sequelize-typescript' 20} from 'sequelize-typescript'
21import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc' 21import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
22import { getServerActor } from '@server/models/application/application' 22import { getServerActor } from '@server/models/application/application'
23import { VideoModel } from '@server/models/video/video'
24import { 23import {
25 MActorFollowActorsDefault, 24 MActorFollowActorsDefault,
26 MActorFollowActorsDefaultSubscription, 25 MActorFollowActorsDefaultSubscription,
@@ -36,6 +35,7 @@ import { logger } from '../../helpers/logger'
36import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants' 35import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants'
37import { AccountModel } from '../account/account' 36import { AccountModel } from '../account/account'
38import { ServerModel } from '../server/server' 37import { ServerModel } from '../server/server'
38import { doesExist } from '../shared/query'
39import { createSafeIn, getFollowsSort, getSort, searchAttribute, throwIfNotValid } from '../utils' 39import { createSafeIn, getFollowsSort, getSort, searchAttribute, throwIfNotValid } from '../utils'
40import { VideoChannelModel } from '../video/video-channel' 40import { VideoChannelModel } from '../video/video-channel'
41import { ActorModel, unusedActorAttributesForAPI } from './actor' 41import { ActorModel, unusedActorAttributesForAPI } from './actor'
@@ -166,14 +166,8 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
166 166
167 static isFollowedBy (actorId: number, followerActorId: number) { 167 static isFollowedBy (actorId: number, followerActorId: number) {
168 const query = 'SELECT 1 FROM "actorFollow" WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId LIMIT 1' 168 const query = 'SELECT 1 FROM "actorFollow" WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId LIMIT 1'
169 const options = {
170 type: QueryTypes.SELECT as QueryTypes.SELECT,
171 bind: { actorId, followerActorId },
172 raw: true
173 }
174 169
175 return VideoModel.sequelize.query(query, options) 170 return doesExist(query, { actorId, followerActorId })
176 .then(results => results.length === 1)
177 } 171 }
178 172
179 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> { 173 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
@@ -324,13 +318,13 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
324 318
325 const followWhere = state ? { state } : {} 319 const followWhere = state ? { state } : {}
326 const followingWhere: WhereOptions = {} 320 const followingWhere: WhereOptions = {}
327 const followingServerWhere: WhereOptions = {}
328 321
329 if (search) { 322 if (search) {
330 Object.assign(followingServerWhere, { 323 Object.assign(followWhere, {
331 host: { 324 [Op.or]: [
332 [Op.iLike]: '%' + search + '%' 325 searchAttribute(options.search, '$ActorFollowing.preferredUsername$'),
333 } 326 searchAttribute(options.search, '$ActorFollowing.Server.host$')
327 ]
334 }) 328 })
335 } 329 }
336 330
@@ -361,8 +355,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
361 include: [ 355 include: [
362 { 356 {
363 model: ServerModel, 357 model: ServerModel,
364 required: true, 358 required: true
365 where: followingServerWhere
366 } 359 }
367 ] 360 ]
368 } 361 }
@@ -391,13 +384,13 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
391 384
392 const followWhere = state ? { state } : {} 385 const followWhere = state ? { state } : {}
393 const followerWhere: WhereOptions = {} 386 const followerWhere: WhereOptions = {}
394 const followerServerWhere: WhereOptions = {}
395 387
396 if (search) { 388 if (search) {
397 Object.assign(followerServerWhere, { 389 Object.assign(followWhere, {
398 host: { 390 [Op.or]: [
399 [Op.iLike]: '%' + search + '%' 391 searchAttribute(search, '$ActorFollower.preferredUsername$'),
400 } 392 searchAttribute(search, '$ActorFollower.Server.host$')
393 ]
401 }) 394 })
402 } 395 }
403 396
@@ -420,8 +413,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
420 include: [ 413 include: [
421 { 414 {
422 model: ServerModel, 415 model: ServerModel,
423 required: true, 416 required: true
424 where: followerServerWhere
425 } 417 }
426 ] 418 ]
427 }, 419 },
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index ccda023e0..d645be248 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -160,8 +160,8 @@ export class VideoRedundancyModel extends Model<Partial<AttributesOnly<VideoRedu
160 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` 160 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
161 logger.info('Removing duplicated video file %s.', logIdentifier) 161 logger.info('Removing duplicated video file %s.', logIdentifier)
162 162
163 videoFile.Video.removeFile(videoFile, true) 163 videoFile.Video.removeFileAndTorrent(videoFile, true)
164 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) 164 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
165 } 165 }
166 166
167 if (instance.videoStreamingPlaylistId) { 167 if (instance.videoStreamingPlaylistId) {
diff --git a/server/models/shared/index.ts b/server/models/shared/index.ts
new file mode 100644
index 000000000..5b97510e0
--- /dev/null
+++ b/server/models/shared/index.ts
@@ -0,0 +1,2 @@
1export * from './query'
2export * from './update'
diff --git a/server/models/shared/query.ts b/server/models/shared/query.ts
new file mode 100644
index 000000000..036cc13c6
--- /dev/null
+++ b/server/models/shared/query.ts
@@ -0,0 +1,17 @@
1import { BindOrReplacements, QueryTypes } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database'
3
4function doesExist (query: string, bind?: BindOrReplacements) {
5 const options = {
6 type: QueryTypes.SELECT as QueryTypes.SELECT,
7 bind,
8 raw: true
9 }
10
11 return sequelizeTypescript.query(query, options)
12 .then(results => results.length === 1)
13}
14
15export {
16 doesExist
17}
diff --git a/server/models/shared/update.ts b/server/models/shared/update.ts
new file mode 100644
index 000000000..d338211e3
--- /dev/null
+++ b/server/models/shared/update.ts
@@ -0,0 +1,18 @@
1import { QueryTypes, Transaction } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database'
3
4// Sequelize always skip the update if we only update updatedAt field
5function setAsUpdated (table: string, id: number, transaction?: Transaction) {
6 return sequelizeTypescript.query(
7 `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`,
8 {
9 replacements: { table, id, updatedAt: new Date() },
10 type: QueryTypes.UPDATE,
11 transaction
12 }
13 )
14}
15
16export {
17 setAsUpdated
18}
diff --git a/server/models/user/user-notification.ts b/server/models/user/user-notification.ts
index a7f84e9ca..04c5513a9 100644
--- a/server/models/user/user-notification.ts
+++ b/server/models/user/user-notification.ts
@@ -1,5 +1,6 @@
1import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize' 1import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { uuidToShort } from '@server/helpers/uuid'
3import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user' 4import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
4import { AttributesOnly } from '@shared/core-utils' 5import { AttributesOnly } from '@shared/core-utils'
5import { UserNotification, UserNotificationType } from '../../../shared' 6import { UserNotification, UserNotificationType } from '../../../shared'
@@ -615,6 +616,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
615 return { 616 return {
616 id: video.id, 617 id: video.id,
617 uuid: video.uuid, 618 uuid: video.uuid,
619 shortUUID: uuidToShort(video.uuid),
618 name: video.name 620 name: video.name
619 } 621 }
620 } 622 }
@@ -628,6 +630,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
628 ? { 630 ? {
629 id: abuse.VideoCommentAbuse.VideoComment.Video.id, 631 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
630 name: abuse.VideoCommentAbuse.VideoComment.Video.name, 632 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
633 shortUUID: uuidToShort(abuse.VideoCommentAbuse.VideoComment.Video.uuid),
631 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid 634 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
632 } 635 }
633 : undefined 636 : undefined
diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts
index ab4cf53a8..8a54de3b0 100644
--- a/server/models/video/formatter/video-format-utils.ts
+++ b/server/models/video/formatter/video-format-utils.ts
@@ -182,8 +182,8 @@ function streamingPlaylistsModelToFormattedJSON (
182 return { 182 return {
183 id: playlist.id, 183 id: playlist.id,
184 type: playlist.type, 184 type: playlist.type,
185 playlistUrl: playlist.playlistUrl, 185 playlistUrl: playlist.getMasterPlaylistUrl(video),
186 segmentsSha256Url: playlist.segmentsSha256Url, 186 segmentsSha256Url: playlist.getSha256SegmentsUrl(video),
187 redundancies, 187 redundancies,
188 files 188 files
189 } 189 }
@@ -331,7 +331,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
331 type: 'Link', 331 type: 'Link',
332 name: 'sha256', 332 name: 'sha256',
333 mediaType: 'application/json' as 'application/json', 333 mediaType: 'application/json' as 'application/json',
334 href: playlist.segmentsSha256Url 334 href: playlist.getSha256SegmentsUrl(video)
335 }) 335 })
336 336
337 addVideoFilesInAPAcc(tag, video, playlist.VideoFiles || []) 337 addVideoFilesInAPAcc(tag, video, playlist.VideoFiles || [])
@@ -339,7 +339,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
339 url.push({ 339 url.push({
340 type: 'Link', 340 type: 'Link',
341 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL', 341 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
342 href: playlist.playlistUrl, 342 href: playlist.getMasterPlaylistUrl(video),
343 tag 343 tag
344 }) 344 })
345 } 345 }
diff --git a/server/models/video/sql/shared/video-tables.ts b/server/models/video/sql/shared/video-tables.ts
index abdd22188..742d19099 100644
--- a/server/models/video/sql/shared/video-tables.ts
+++ b/server/models/video/sql/shared/video-tables.ts
@@ -92,12 +92,13 @@ export class VideoTables {
92 } 92 }
93 93
94 getStreamingPlaylistAttributes () { 94 getStreamingPlaylistAttributes () {
95 let playlistKeys = [ 'id', 'playlistUrl', 'type' ] 95 let playlistKeys = [ 'id', 'playlistUrl', 'playlistFilename', 'type' ]
96 96
97 if (this.mode === 'get') { 97 if (this.mode === 'get') {
98 playlistKeys = playlistKeys.concat([ 98 playlistKeys = playlistKeys.concat([
99 'p2pMediaLoaderInfohashes', 99 'p2pMediaLoaderInfohashes',
100 'p2pMediaLoaderPeerVersion', 100 'p2pMediaLoaderPeerVersion',
101 'segmentsSha256Filename',
101 'segmentsSha256Url', 102 'segmentsSha256Url',
102 'videoId', 103 'videoId',
103 'createdAt', 104 'createdAt',
diff --git a/server/models/video/sql/videos-id-list-query-builder.ts b/server/models/video/sql/videos-id-list-query-builder.ts
index 30b251f0f..7625c003d 100644
--- a/server/models/video/sql/videos-id-list-query-builder.ts
+++ b/server/models/video/sql/videos-id-list-query-builder.ts
@@ -1,6 +1,7 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import validator from 'validator' 2import validator from 'validator'
3import { exists } from '@server/helpers/custom-validators/misc' 3import { exists } from '@server/helpers/custom-validators/misc'
4import { WEBSERVER } from '@server/initializers/constants'
4import { buildDirectionAndField, createSafeIn } from '@server/models/utils' 5import { buildDirectionAndField, createSafeIn } from '@server/models/utils'
5import { MUserAccountId, MUserId } from '@server/types/models' 6import { MUserAccountId, MUserId } from '@server/types/models'
6import { VideoFilter, VideoPrivacy, VideoState } from '@shared/models' 7import { VideoFilter, VideoPrivacy, VideoState } from '@shared/models'
@@ -25,6 +26,7 @@ export type BuildVideosListQueryOptions = {
25 26
26 nsfw?: boolean 27 nsfw?: boolean
27 filter?: VideoFilter 28 filter?: VideoFilter
29 host?: string
28 isLive?: boolean 30 isLive?: boolean
29 31
30 categoryOneOf?: number[] 32 categoryOneOf?: number[]
@@ -33,6 +35,8 @@ export type BuildVideosListQueryOptions = {
33 tagsOneOf?: string[] 35 tagsOneOf?: string[]
34 tagsAllOf?: string[] 36 tagsAllOf?: string[]
35 37
38 uuids?: string[]
39
36 withFiles?: boolean 40 withFiles?: boolean
37 41
38 accountId?: number 42 accountId?: number
@@ -131,6 +135,10 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
131 this.whereOnlyLocal() 135 this.whereOnlyLocal()
132 } 136 }
133 137
138 if (options.host) {
139 this.whereHost(options.host)
140 }
141
134 if (options.accountId) { 142 if (options.accountId) {
135 this.whereAccountId(options.accountId) 143 this.whereAccountId(options.accountId)
136 } 144 }
@@ -155,6 +163,10 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
155 this.whereTagsAllOf(options.tagsAllOf) 163 this.whereTagsAllOf(options.tagsAllOf)
156 } 164 }
157 165
166 if (options.uuids) {
167 this.whereUUIDs(options.uuids)
168 }
169
158 if (options.nsfw === true) { 170 if (options.nsfw === true) {
159 this.whereNSFW() 171 this.whereNSFW()
160 } else if (options.nsfw === false) { 172 } else if (options.nsfw === false) {
@@ -291,6 +303,19 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
291 this.and.push('"video"."remote" IS FALSE') 303 this.and.push('"video"."remote" IS FALSE')
292 } 304 }
293 305
306 private whereHost (host: string) {
307 // Local instance
308 if (host === WEBSERVER.HOST) {
309 this.and.push('"accountActor"."serverId" IS NULL')
310 return
311 }
312
313 this.joins.push('INNER JOIN "server" ON "server"."id" = "accountActor"."serverId"')
314
315 this.and.push('"server"."host" = :host')
316 this.replacements.host = host
317 }
318
294 private whereAccountId (accountId: number) { 319 private whereAccountId (accountId: number) {
295 this.and.push('"account"."id" = :accountId') 320 this.and.push('"account"."id" = :accountId')
296 this.replacements.accountId = accountId 321 this.replacements.accountId = accountId
@@ -304,16 +329,16 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
304 private whereFollowerActorId (followerActorId: number, includeLocalVideos: boolean) { 329 private whereFollowerActorId (followerActorId: number, includeLocalVideos: boolean) {
305 let query = 330 let query =
306 '(' + 331 '(' +
307 ' EXISTS (' + 332 ' EXISTS (' + // Videos shared by actors we follow
308 ' SELECT 1 FROM "videoShare" ' + 333 ' SELECT 1 FROM "videoShare" ' +
309 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' + 334 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
310 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' + 335 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
311 ' WHERE "videoShare"."videoId" = "video"."id"' + 336 ' WHERE "videoShare"."videoId" = "video"."id"' +
312 ' )' + 337 ' )' +
313 ' OR' + 338 ' OR' +
314 ' EXISTS (' + 339 ' EXISTS (' + // Videos published by accounts we follow
315 ' SELECT 1 from "actorFollow" ' + 340 ' SELECT 1 from "actorFollow" ' +
316 ' WHERE "actorFollow"."targetActorId" = "videoChannel"."actorId" AND "actorFollow"."actorId" = :followerActorId ' + 341 ' WHERE "actorFollow"."targetActorId" = "account"."actorId" AND "actorFollow"."actorId" = :followerActorId ' +
317 ' AND "actorFollow"."state" = \'accepted\'' + 342 ' AND "actorFollow"."state" = \'accepted\'' +
318 ' )' 343 ' )'
319 344
@@ -367,6 +392,10 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
367 ) 392 )
368 } 393 }
369 394
395 private whereUUIDs (uuids: string[]) {
396 this.and.push('"video"."uuid" IN (' + createSafeIn(this.sequelize, uuids) + ')')
397 }
398
370 private whereCategoryOneOf (categoryOneOf: number[]) { 399 private whereCategoryOneOf (categoryOneOf: number[]) {
371 this.and.push('"video"."category" IN (:categoryOneOf)') 400 this.and.push('"video"."category" IN (:categoryOneOf)')
372 this.replacements.categoryOneOf = categoryOneOf 401 this.replacements.categoryOneOf = categoryOneOf
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 183e7448c..9f04a57c6 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -1,4 +1,4 @@
1import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction } from 'sequelize' 1import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
2import { 2import {
3 AllowNull, 3 AllowNull,
4 BeforeDestroy, 4 BeforeDestroy,
@@ -17,9 +17,8 @@ import {
17 Table, 17 Table,
18 UpdatedAt 18 UpdatedAt
19} from 'sequelize-typescript' 19} from 'sequelize-typescript'
20import { setAsUpdated } from '@server/helpers/database-utils'
21import { MAccountActor } from '@server/types/models' 20import { MAccountActor } from '@server/types/models'
22import { AttributesOnly } from '@shared/core-utils' 21import { AttributesOnly, pick } from '@shared/core-utils'
23import { ActivityPubActor } from '../../../shared/models/activitypub' 22import { ActivityPubActor } from '../../../shared/models/activitypub'
24import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos' 23import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
25import { 24import {
@@ -41,6 +40,7 @@ import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
41import { ActorFollowModel } from '../actor/actor-follow' 40import { ActorFollowModel } from '../actor/actor-follow'
42import { ActorImageModel } from '../actor/actor-image' 41import { ActorImageModel } from '../actor/actor-image'
43import { ServerModel } from '../server/server' 42import { ServerModel } from '../server/server'
43import { setAsUpdated } from '../shared'
44import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 44import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
45import { VideoModel } from './video' 45import { VideoModel } from './video'
46import { VideoPlaylistModel } from './video-playlist' 46import { VideoPlaylistModel } from './video-playlist'
@@ -58,6 +58,8 @@ export enum ScopeNames {
58type AvailableForListOptions = { 58type AvailableForListOptions = {
59 actorId: number 59 actorId: number
60 search?: string 60 search?: string
61 host?: string
62 handles?: string[]
61} 63}
62 64
63type AvailableWithStatsOptions = { 65type AvailableWithStatsOptions = {
@@ -83,7 +85,62 @@ export type SummaryOptions = {
83 // Only list local channels OR channels that are on an instance followed by actorId 85 // Only list local channels OR channels that are on an instance followed by actorId
84 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) 86 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
85 87
88 const whereActorAnd: WhereOptions[] = [
89 {
90 [Op.or]: [
91 {
92 serverId: null
93 },
94 {
95 serverId: {
96 [Op.in]: Sequelize.literal(inQueryInstanceFollow)
97 }
98 }
99 ]
100 }
101 ]
102
103 let serverRequired = false
104 let whereServer: WhereOptions
105
106 if (options.host && options.host !== WEBSERVER.HOST) {
107 serverRequired = true
108 whereServer = { host: options.host }
109 }
110
111 if (options.host === WEBSERVER.HOST) {
112 whereActorAnd.push({
113 serverId: null
114 })
115 }
116
117 let rootWhere: WhereOptions
118 if (options.handles) {
119 const or: WhereOptions[] = []
120
121 for (const handle of options.handles || []) {
122 const [ preferredUsername, host ] = handle.split('@')
123
124 if (!host) {
125 or.push({
126 '$Actor.preferredUsername$': preferredUsername,
127 '$Actor.serverId$': null
128 })
129 } else {
130 or.push({
131 '$Actor.preferredUsername$': preferredUsername,
132 '$Actor.Server.host$': host
133 })
134 }
135 }
136
137 rootWhere = {
138 [Op.or]: or
139 }
140 }
141
86 return { 142 return {
143 where: rootWhere,
87 include: [ 144 include: [
88 { 145 {
89 attributes: { 146 attributes: {
@@ -91,19 +148,20 @@ export type SummaryOptions = {
91 }, 148 },
92 model: ActorModel, 149 model: ActorModel,
93 where: { 150 where: {
94 [Op.or]: [ 151 [Op.and]: whereActorAnd
95 {
96 serverId: null
97 },
98 {
99 serverId: {
100 [Op.in]: Sequelize.literal(inQueryInstanceFollow)
101 }
102 }
103 ]
104 }, 152 },
105 include: [ 153 include: [
106 { 154 {
155 model: ServerModel,
156 required: serverRequired,
157 where: whereServer
158 },
159 {
160 model: ActorImageModel,
161 as: 'Avatar',
162 required: false
163 },
164 {
107 model: ActorImageModel, 165 model: ActorImageModel,
108 as: 'Banner', 166 as: 'Banner',
109 required: false 167 required: false
@@ -380,30 +438,6 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
380 } 438 }
381 } 439 }
382 440
383 static listForApi (parameters: {
384 actorId: number
385 start: number
386 count: number
387 sort: string
388 }) {
389 const { actorId } = parameters
390
391 const query = {
392 offset: parameters.start,
393 limit: parameters.count,
394 order: getSort(parameters.sort)
395 }
396
397 return VideoChannelModel
398 .scope({
399 method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ]
400 })
401 .findAndCountAll(query)
402 .then(({ rows, count }) => {
403 return { total: count, data: rows }
404 })
405 }
406
407 static listLocalsForSitemap (sort: string): Promise<MChannelActor[]> { 441 static listLocalsForSitemap (sort: string): Promise<MChannelActor[]> {
408 const query = { 442 const query = {
409 attributes: [ ], 443 attributes: [ ],
@@ -425,26 +459,43 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
425 .findAll(query) 459 .findAll(query)
426 } 460 }
427 461
428 static searchForApi (options: { 462 static listForApi (parameters: Pick<AvailableForListOptions, 'actorId'> & {
429 actorId: number
430 search: string
431 start: number 463 start: number
432 count: number 464 count: number
433 sort: string 465 sort: string
434 }) { 466 }) {
435 const attributesInclude = [] 467 const { actorId } = parameters
436 const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
437 const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
438 attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
439 468
440 const query = { 469 const query = {
441 attributes: { 470 offset: parameters.start,
442 include: attributesInclude 471 limit: parameters.count,
443 }, 472 order: getSort(parameters.sort)
444 offset: options.start, 473 }
445 limit: options.count, 474
446 order: getSort(options.sort), 475 return VideoChannelModel
447 where: { 476 .scope({
477 method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ]
478 })
479 .findAndCountAll(query)
480 .then(({ rows, count }) => {
481 return { total: count, data: rows }
482 })
483 }
484
485 static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
486 start: number
487 count: number
488 sort: string
489 }) {
490 let attributesInclude: any[] = [ literal('0 as similarity') ]
491 let where: WhereOptions
492
493 if (options.search) {
494 const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
495 const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
496 attributesInclude = [ createSimilarityAttribute('VideoChannelModel.name', options.search) ]
497
498 where = {
448 [Op.or]: [ 499 [Op.or]: [
449 Sequelize.literal( 500 Sequelize.literal(
450 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))' 501 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
@@ -456,9 +507,19 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
456 } 507 }
457 } 508 }
458 509
510 const query = {
511 attributes: {
512 include: attributesInclude
513 },
514 offset: options.start,
515 limit: options.count,
516 order: getSort(options.sort),
517 where
518 }
519
459 return VideoChannelModel 520 return VideoChannelModel
460 .scope({ 521 .scope({
461 method: [ ScopeNames.FOR_API, { actorId: options.actorId } as AvailableForListOptions ] 522 method: [ ScopeNames.FOR_API, pick(options, [ 'actorId', 'host', 'handles' ]) as AvailableForListOptions ]
462 }) 523 })
463 .findAndCountAll(query) 524 .findAndCountAll(query)
464 .then(({ rows, count }) => { 525 .then(({ rows, count }) => {
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 22cf63804..09fc5288b 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -1,7 +1,7 @@
1import { remove } from 'fs-extra' 1import { remove } from 'fs-extra'
2import * as memoizee from 'memoizee' 2import * as memoizee from 'memoizee'
3import { join } from 'path' 3import { join } from 'path'
4import { FindOptions, Op, QueryTypes, Transaction } from 'sequelize' 4import { FindOptions, Op, Transaction } from 'sequelize'
5import { 5import {
6 AllowNull, 6 AllowNull,
7 BelongsTo, 7 BelongsTo,
@@ -44,6 +44,7 @@ import {
44} from '../../initializers/constants' 44} from '../../initializers/constants'
45import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file' 45import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
46import { VideoRedundancyModel } from '../redundancy/video-redundancy' 46import { VideoRedundancyModel } from '../redundancy/video-redundancy'
47import { doesExist } from '../shared'
47import { parseAggregateResult, throwIfNotValid } from '../utils' 48import { parseAggregateResult, throwIfNotValid } from '../utils'
48import { VideoModel } from './video' 49import { VideoModel } from './video'
49import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 50import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
@@ -250,14 +251,8 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
250 251
251 static doesInfohashExist (infoHash: string) { 252 static doesInfohashExist (infoHash: string) {
252 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 253 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
253 const options = {
254 type: QueryTypes.SELECT as QueryTypes.SELECT,
255 bind: { infoHash },
256 raw: true
257 }
258 254
259 return VideoModel.sequelize.query(query, options) 255 return doesExist(query, { infoHash })
260 .then(results => results.length === 1)
261 } 256 }
262 257
263 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) { 258 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
@@ -266,6 +261,33 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
266 return !!videoFile 261 return !!videoFile
267 } 262 }
268 263
264 static async doesOwnedTorrentFileExist (filename: string) {
265 const query = 'SELECT 1 FROM "videoFile" ' +
266 'LEFT JOIN "video" "webtorrent" ON "webtorrent"."id" = "videoFile"."videoId" AND "webtorrent"."remote" IS FALSE ' +
267 'LEFT JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' +
268 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
269 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1'
270
271 return doesExist(query, { filename })
272 }
273
274 static async doesOwnedWebTorrentVideoFileExist (filename: string) {
275 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
276 'WHERE "filename" = $filename LIMIT 1'
277
278 return doesExist(query, { filename })
279 }
280
281 static loadByFilename (filename: string) {
282 const query = {
283 where: {
284 filename
285 }
286 }
287
288 return VideoFileModel.findOne(query)
289 }
290
269 static loadWithVideoOrPlaylistByTorrentFilename (filename: string) { 291 static loadWithVideoOrPlaylistByTorrentFilename (filename: string) {
270 const query = { 292 const query = {
271 where: { 293 where: {
@@ -443,10 +465,9 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
443 } 465 }
444 466
445 getFileDownloadUrl (video: MVideoWithHost) { 467 getFileDownloadUrl (video: MVideoWithHost) {
446 const basePath = this.isHLS() 468 const path = this.isHLS()
447 ? STATIC_DOWNLOAD_PATHS.HLS_VIDEOS 469 ? join(STATIC_DOWNLOAD_PATHS.HLS_VIDEOS, `${video.uuid}-${this.resolution}-fragmented${this.extname}`)
448 : STATIC_DOWNLOAD_PATHS.VIDEOS 470 : join(STATIC_DOWNLOAD_PATHS.VIDEOS, `${video.uuid}-${this.resolution}${this.extname}`)
449 const path = join(basePath, this.filename)
450 471
451 if (video.isOwned()) return WEBSERVER.URL + path 472 if (video.isOwned()) return WEBSERVER.URL + path
452 473
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index af81c9906..630684a88 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -17,10 +17,9 @@ import {
17 Table, 17 Table,
18 UpdatedAt 18 UpdatedAt
19} from 'sequelize-typescript' 19} from 'sequelize-typescript'
20import { setAsUpdated } from '@server/helpers/database-utils'
21import { buildUUID, uuidToShort } from '@server/helpers/uuid' 20import { buildUUID, uuidToShort } from '@server/helpers/uuid'
22import { MAccountId, MChannelId } from '@server/types/models' 21import { MAccountId, MChannelId } from '@server/types/models'
23import { AttributesOnly } from '@shared/core-utils' 22import { AttributesOnly, buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
24import { ActivityIconObject } from '../../../shared/models/activitypub/objects' 23import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
25import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' 24import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
26import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' 25import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
@@ -53,6 +52,7 @@ import {
53} from '../../types/models/video/video-playlist' 52} from '../../types/models/video/video-playlist'
54import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' 53import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
55import { ActorModel } from '../actor/actor' 54import { ActorModel } from '../actor/actor'
55import { setAsUpdated } from '../shared'
56import { 56import {
57 buildServerIdsFollowedBy, 57 buildServerIdsFollowedBy,
58 buildTrigramSearchIndex, 58 buildTrigramSearchIndex,
@@ -82,6 +82,8 @@ type AvailableForListOptions = {
82 videoChannelId?: number 82 videoChannelId?: number
83 listMyPlaylists?: boolean 83 listMyPlaylists?: boolean
84 search?: string 84 search?: string
85 host?: string
86 uuids?: string[]
85 withVideos?: boolean 87 withVideos?: boolean
86} 88}
87 89
@@ -141,9 +143,19 @@ function getVideoLengthSelect () {
141 ] 143 ]
142 }, 144 },
143 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { 145 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
146 const whereAnd: WhereOptions[] = []
147
148 const whereServer = options.host && options.host !== WEBSERVER.HOST
149 ? { host: options.host }
150 : undefined
151
144 let whereActor: WhereOptions = {} 152 let whereActor: WhereOptions = {}
145 153
146 const whereAnd: WhereOptions[] = [] 154 if (options.host === WEBSERVER.HOST) {
155 whereActor = {
156 [Op.and]: [ { serverId: null } ]
157 }
158 }
147 159
148 if (options.listMyPlaylists !== true) { 160 if (options.listMyPlaylists !== true) {
149 whereAnd.push({ 161 whereAnd.push({
@@ -168,9 +180,7 @@ function getVideoLengthSelect () {
168 }) 180 })
169 } 181 }
170 182
171 whereActor = { 183 Object.assign(whereActor, { [Op.or]: whereActorOr })
172 [Op.or]: whereActorOr
173 }
174 } 184 }
175 185
176 if (options.accountId) { 186 if (options.accountId) {
@@ -191,18 +201,26 @@ function getVideoLengthSelect () {
191 }) 201 })
192 } 202 }
193 203
204 if (options.uuids) {
205 whereAnd.push({
206 uuid: {
207 [Op.in]: options.uuids
208 }
209 })
210 }
211
194 if (options.withVideos === true) { 212 if (options.withVideos === true) {
195 whereAnd.push( 213 whereAnd.push(
196 literal(`(${getVideoLengthSelect()}) != 0`) 214 literal(`(${getVideoLengthSelect()}) != 0`)
197 ) 215 )
198 } 216 }
199 217
200 const attributesInclude = [] 218 let attributesInclude: any[] = [ literal('0 as similarity') ]
201 219
202 if (options.search) { 220 if (options.search) {
203 const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search) 221 const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search)
204 const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%') 222 const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%')
205 attributesInclude.push(createSimilarityAttribute('VideoPlaylistModel.name', options.search)) 223 attributesInclude = [ createSimilarityAttribute('VideoPlaylistModel.name', options.search) ]
206 224
207 whereAnd.push({ 225 whereAnd.push({
208 [Op.or]: [ 226 [Op.or]: [
@@ -228,7 +246,7 @@ function getVideoLengthSelect () {
228 include: [ 246 include: [
229 { 247 {
230 model: AccountModel.scope({ 248 model: AccountModel.scope({
231 method: [ AccountScopeNames.SUMMARY, { whereActor } as SummaryOptions ] 249 method: [ AccountScopeNames.SUMMARY, { whereActor, whereServer } as SummaryOptions ]
232 }), 250 }),
233 required: true 251 required: true
234 }, 252 },
@@ -339,17 +357,10 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
339 }) 357 })
340 Thumbnail: ThumbnailModel 358 Thumbnail: ThumbnailModel
341 359
342 static listForApi (options: { 360 static listForApi (options: AvailableForListOptions & {
343 followerActorId: number
344 start: number 361 start: number
345 count: number 362 count: number
346 sort: string 363 sort: string
347 type?: VideoPlaylistType
348 accountId?: number
349 videoChannelId?: number
350 listMyPlaylists?: boolean
351 search?: string
352 withVideos?: boolean // false by default
353 }) { 364 }) {
354 const query = { 365 const query = {
355 offset: options.start, 366 offset: options.start,
@@ -362,12 +373,8 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
362 method: [ 373 method: [
363 ScopeNames.AVAILABLE_FOR_LIST, 374 ScopeNames.AVAILABLE_FOR_LIST,
364 { 375 {
365 type: options.type, 376 ...pick(options, [ 'type', 'followerActorId', 'accountId', 'videoChannelId', 'listMyPlaylists', 'search', 'host', 'uuids' ]),
366 followerActorId: options.followerActorId, 377
367 accountId: options.accountId,
368 videoChannelId: options.videoChannelId,
369 listMyPlaylists: options.listMyPlaylists,
370 search: options.search,
371 withVideos: options.withVideos || false 378 withVideos: options.withVideos || false
372 } as AvailableForListOptions 379 } as AvailableForListOptions
373 ] 380 ]
@@ -384,15 +391,14 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
384 }) 391 })
385 } 392 }
386 393
387 static searchForApi (options: { 394 static searchForApi (options: Pick<AvailableForListOptions, 'followerActorId' | 'search'| 'host'| 'uuids'> & {
388 followerActorId: number
389 start: number 395 start: number
390 count: number 396 count: number
391 sort: string 397 sort: string
392 search?: string
393 }) { 398 }) {
394 return VideoPlaylistModel.listForApi({ 399 return VideoPlaylistModel.listForApi({
395 ...options, 400 ...options,
401
396 type: VideoPlaylistType.REGULAR, 402 type: VideoPlaylistType.REGULAR,
397 listMyPlaylists: false, 403 listMyPlaylists: false,
398 withVideos: true 404 withVideos: true
@@ -560,12 +566,12 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
560 return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename) 566 return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
561 } 567 }
562 568
563 getWatchUrl () { 569 getWatchStaticPath () {
564 return WEBSERVER.URL + '/w/p/' + this.uuid 570 return buildPlaylistWatchPath({ shortUUID: uuidToShort(this.uuid) })
565 } 571 }
566 572
567 getEmbedStaticPath () { 573 getEmbedStaticPath () {
568 return '/video-playlists/embed/' + this.uuid 574 return buildPlaylistEmbedPath(this)
569 } 575 }
570 576
571 static async getStats () { 577 static async getStats () {
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index d627e8c9d..d591a3134 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -1,19 +1,27 @@
1import * as memoizee from 'memoizee' 1import * as memoizee from 'memoizee'
2import { join } from 'path' 2import { join } from 'path'
3import { Op, QueryTypes } from 'sequelize' 3import { Op } from 'sequelize'
4import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 4import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
5import { VideoFileModel } from '@server/models/video/video-file' 5import { VideoFileModel } from '@server/models/video/video-file'
6import { MStreamingPlaylist } from '@server/types/models' 6import { MStreamingPlaylist, MVideo } from '@server/types/models'
7import { AttributesOnly } from '@shared/core-utils'
7import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' 8import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
8import { sha1 } from '../../helpers/core-utils' 9import { sha1 } from '../../helpers/core-utils'
9import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 10import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
10import { isArrayOf } from '../../helpers/custom-validators/misc' 11import { isArrayOf } from '../../helpers/custom-validators/misc'
11import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' 12import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
12import { CONSTRAINTS_FIELDS, MEMOIZE_LENGTH, MEMOIZE_TTL, P2P_MEDIA_LOADER_PEER_VERSION, STATIC_PATHS } from '../../initializers/constants' 13import {
14 CONSTRAINTS_FIELDS,
15 MEMOIZE_LENGTH,
16 MEMOIZE_TTL,
17 P2P_MEDIA_LOADER_PEER_VERSION,
18 STATIC_PATHS,
19 WEBSERVER
20} from '../../initializers/constants'
13import { VideoRedundancyModel } from '../redundancy/video-redundancy' 21import { VideoRedundancyModel } from '../redundancy/video-redundancy'
22import { doesExist } from '../shared'
14import { throwIfNotValid } from '../utils' 23import { throwIfNotValid } from '../utils'
15import { VideoModel } from './video' 24import { VideoModel } from './video'
16import { AttributesOnly } from '@shared/core-utils'
17 25
18@Table({ 26@Table({
19 tableName: 'videoStreamingPlaylist', 27 tableName: 'videoStreamingPlaylist',
@@ -43,7 +51,11 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
43 type: VideoStreamingPlaylistType 51 type: VideoStreamingPlaylistType
44 52
45 @AllowNull(false) 53 @AllowNull(false)
46 @Is('PlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'playlist url')) 54 @Column
55 playlistFilename: string
56
57 @AllowNull(true)
58 @Is('PlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'playlist url', true))
47 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max)) 59 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
48 playlistUrl: string 60 playlistUrl: string
49 61
@@ -57,7 +69,11 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
57 p2pMediaLoaderPeerVersion: number 69 p2pMediaLoaderPeerVersion: number
58 70
59 @AllowNull(false) 71 @AllowNull(false)
60 @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url')) 72 @Column
73 segmentsSha256Filename: string
74
75 @AllowNull(true)
76 @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url', true))
61 @Column 77 @Column
62 segmentsSha256Url: string 78 segmentsSha256Url: string
63 79
@@ -98,14 +114,8 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
98 114
99 static doesInfohashExist (infoHash: string) { 115 static doesInfohashExist (infoHash: string) {
100 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1' 116 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
101 const options = {
102 type: QueryTypes.SELECT as QueryTypes.SELECT,
103 bind: { infoHash },
104 raw: true
105 }
106 117
107 return VideoModel.sequelize.query<object>(query, options) 118 return doesExist(query, { infoHash })
108 .then(results => results.length === 1)
109 } 119 }
110 120
111 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) { 121 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
@@ -125,7 +135,13 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
125 p2pMediaLoaderPeerVersion: { 135 p2pMediaLoaderPeerVersion: {
126 [Op.ne]: P2P_MEDIA_LOADER_PEER_VERSION 136 [Op.ne]: P2P_MEDIA_LOADER_PEER_VERSION
127 } 137 }
128 } 138 },
139 include: [
140 {
141 model: VideoModel.unscoped(),
142 required: true
143 }
144 ]
129 } 145 }
130 146
131 return VideoStreamingPlaylistModel.findAll(query) 147 return VideoStreamingPlaylistModel.findAll(query)
@@ -144,7 +160,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
144 return VideoStreamingPlaylistModel.findByPk(id, options) 160 return VideoStreamingPlaylistModel.findByPk(id, options)
145 } 161 }
146 162
147 static loadHLSPlaylistByVideo (videoId: number) { 163 static loadHLSPlaylistByVideo (videoId: number): Promise<MStreamingPlaylist> {
148 const options = { 164 const options = {
149 where: { 165 where: {
150 type: VideoStreamingPlaylistType.HLS, 166 type: VideoStreamingPlaylistType.HLS,
@@ -155,30 +171,29 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
155 return VideoStreamingPlaylistModel.findOne(options) 171 return VideoStreamingPlaylistModel.findOne(options)
156 } 172 }
157 173
158 static getHlsPlaylistFilename (resolution: number) { 174 static async loadOrGenerate (video: MVideo) {
159 return resolution + '.m3u8' 175 let playlist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
160 } 176 if (!playlist) playlist = new VideoStreamingPlaylistModel()
161 177
162 static getMasterHlsPlaylistFilename () { 178 return Object.assign(playlist, { videoId: video.id, Video: video })
163 return 'master.m3u8'
164 } 179 }
165 180
166 static getHlsSha256SegmentsFilename () { 181 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
167 return 'segments-sha256.json' 182 const masterPlaylistUrl = this.getMasterPlaylistUrl(video)
168 }
169 183
170 static getHlsMasterPlaylistStaticPath (videoUUID: string) { 184 this.p2pMediaLoaderInfohashes = VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(masterPlaylistUrl, files)
171 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
172 } 185 }
173 186
174 static getHlsPlaylistStaticPath (videoUUID: string, resolution: number) { 187 getMasterPlaylistUrl (video: MVideo) {
175 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution)) 188 if (video.isOwned()) return WEBSERVER.URL + this.getMasterPlaylistStaticPath(video.uuid)
189
190 return this.playlistUrl
176 } 191 }
177 192
178 static getHlsSha256SegmentsStaticPath (videoUUID: string, isLive: boolean) { 193 getSha256SegmentsUrl (video: MVideo) {
179 if (isLive) return join('/live', 'segments-sha256', videoUUID) 194 if (video.isOwned()) return WEBSERVER.URL + this.getSha256SegmentsStaticPath(video.uuid, video.isLive)
180 195
181 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename()) 196 return this.segmentsSha256Url
182 } 197 }
183 198
184 getStringType () { 199 getStringType () {
@@ -195,4 +210,14 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
195 return this.type === other.type && 210 return this.type === other.type &&
196 this.videoId === other.videoId 211 this.videoId === other.videoId
197 } 212 }
213
214 private getMasterPlaylistStaticPath (videoUUID: string) {
215 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, this.playlistFilename)
216 }
217
218 private getSha256SegmentsStaticPath (videoUUID: string, isLive: boolean) {
219 if (isLive) return join('/live', 'segments-sha256', videoUUID)
220
221 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, this.segmentsSha256Filename)
222 }
198} 223}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 1e5648a36..56a5b0e18 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -24,14 +24,14 @@ import {
24 Table, 24 Table,
25 UpdatedAt 25 UpdatedAt
26} from 'sequelize-typescript' 26} from 'sequelize-typescript'
27import { setAsUpdated } from '@server/helpers/database-utils'
28import { buildNSFWFilter } from '@server/helpers/express-utils' 27import { buildNSFWFilter } from '@server/helpers/express-utils'
28import { uuidToShort } from '@server/helpers/uuid'
29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' 29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
30import { LiveManager } from '@server/lib/live/live-manager' 30import { LiveManager } from '@server/lib/live/live-manager'
31import { getHLSDirectory, getVideoFilePath } from '@server/lib/video-paths' 31import { getHLSDirectory, getVideoFilePath } from '@server/lib/video-paths'
32import { getServerActor } from '@server/models/application/application' 32import { getServerActor } from '@server/models/application/application'
33import { ModelCache } from '@server/models/model-cache' 33import { ModelCache } from '@server/models/model-cache'
34import { AttributesOnly } from '@shared/core-utils' 34import { AttributesOnly, buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
35import { VideoFile } from '@shared/models/videos/video-file.model' 35import { VideoFile } from '@shared/models/videos/video-file.model'
36import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared' 36import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared'
37import { VideoObject } from '../../../shared/models/activitypub/objects' 37import { VideoObject } from '../../../shared/models/activitypub/objects'
@@ -91,6 +91,7 @@ import { VideoRedundancyModel } from '../redundancy/video-redundancy'
91import { ServerModel } from '../server/server' 91import { ServerModel } from '../server/server'
92import { TrackerModel } from '../server/tracker' 92import { TrackerModel } from '../server/tracker'
93import { VideoTrackerModel } from '../server/video-tracker' 93import { VideoTrackerModel } from '../server/video-tracker'
94import { setAsUpdated } from '../shared'
94import { UserModel } from '../user/user' 95import { UserModel } from '../user/user'
95import { UserVideoHistoryModel } from '../user/user-video-history' 96import { UserVideoHistoryModel } from '../user/user-video-history'
96import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' 97import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
@@ -762,8 +763,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
762 763
763 // Remove physical files and torrents 764 // Remove physical files and torrents
764 instance.VideoFiles.forEach(file => { 765 instance.VideoFiles.forEach(file => {
765 tasks.push(instance.removeFile(file)) 766 tasks.push(instance.removeFileAndTorrent(file))
766 tasks.push(file.removeTorrent())
767 }) 767 })
768 768
769 // Remove playlists file 769 // Remove playlists file
@@ -1070,7 +1070,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1070 const trendingDays = options.sort.endsWith('trending') 1070 const trendingDays = options.sort.endsWith('trending')
1071 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS 1071 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1072 : undefined 1072 : undefined
1073 let trendingAlgorithm 1073
1074 let trendingAlgorithm: string
1074 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot' 1075 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1075 if (options.sort.endsWith('best')) trendingAlgorithm = 'best' 1076 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1076 1077
@@ -1082,40 +1083,44 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1082 : serverActor.id 1083 : serverActor.id
1083 1084
1084 const queryOptions = { 1085 const queryOptions = {
1085 start: options.start, 1086 ...pick(options, [
1086 count: options.count, 1087 'start',
1087 sort: options.sort, 1088 'count',
1089 'sort',
1090 'nsfw',
1091 'isLive',
1092 'categoryOneOf',
1093 'licenceOneOf',
1094 'languageOneOf',
1095 'tagsOneOf',
1096 'tagsAllOf',
1097 'filter',
1098 'withFiles',
1099 'accountId',
1100 'videoChannelId',
1101 'videoPlaylistId',
1102 'includeLocalVideos',
1103 'user',
1104 'historyOfUser',
1105 'search'
1106 ]),
1107
1088 followerActorId, 1108 followerActorId,
1089 serverAccountId: serverActor.Account.id, 1109 serverAccountId: serverActor.Account.id,
1090 nsfw: options.nsfw,
1091 isLive: options.isLive,
1092 categoryOneOf: options.categoryOneOf,
1093 licenceOneOf: options.licenceOneOf,
1094 languageOneOf: options.languageOneOf,
1095 tagsOneOf: options.tagsOneOf,
1096 tagsAllOf: options.tagsAllOf,
1097 filter: options.filter,
1098 withFiles: options.withFiles,
1099 accountId: options.accountId,
1100 videoChannelId: options.videoChannelId,
1101 videoPlaylistId: options.videoPlaylistId,
1102 includeLocalVideos: options.includeLocalVideos,
1103 user: options.user,
1104 historyOfUser: options.historyOfUser,
1105 trendingDays, 1110 trendingDays,
1106 trendingAlgorithm, 1111 trendingAlgorithm
1107 search: options.search
1108 } 1112 }
1109 1113
1110 return VideoModel.getAvailableForApi(queryOptions, options.countVideos) 1114 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1111 } 1115 }
1112 1116
1113 static async searchAndPopulateAccountAndServer (options: { 1117 static async searchAndPopulateAccountAndServer (options: {
1118 start: number
1119 count: number
1120 sort: string
1114 includeLocalVideos: boolean 1121 includeLocalVideos: boolean
1115 search?: string 1122 search?: string
1116 start?: number 1123 host?: string
1117 count?: number
1118 sort?: string
1119 startDate?: string // ISO 8601 1124 startDate?: string // ISO 8601
1120 endDate?: string // ISO 8601 1125 endDate?: string // ISO 8601
1121 originallyPublishedStartDate?: string 1126 originallyPublishedStartDate?: string
@@ -1131,41 +1136,38 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1131 durationMax?: number // seconds 1136 durationMax?: number // seconds
1132 user?: MUserAccountId 1137 user?: MUserAccountId
1133 filter?: VideoFilter 1138 filter?: VideoFilter
1139 uuids?: string[]
1134 }) { 1140 }) {
1135 const serverActor = await getServerActor() 1141 const serverActor = await getServerActor()
1136 1142
1137 const queryOptions = { 1143 const queryOptions = {
1138 followerActorId: serverActor.id, 1144 ...pick(options, [
1139 serverAccountId: serverActor.Account.id, 1145 'includeLocalVideos',
1140 1146 'nsfw',
1141 includeLocalVideos: options.includeLocalVideos, 1147 'isLive',
1142 nsfw: options.nsfw, 1148 'categoryOneOf',
1143 isLive: options.isLive, 1149 'licenceOneOf',
1144 1150 'languageOneOf',
1145 categoryOneOf: options.categoryOneOf, 1151 'tagsOneOf',
1146 licenceOneOf: options.licenceOneOf, 1152 'tagsAllOf',
1147 languageOneOf: options.languageOneOf, 1153 'user',
1154 'filter',
1155 'host',
1156 'start',
1157 'count',
1158 'sort',
1159 'startDate',
1160 'endDate',
1161 'originallyPublishedStartDate',
1162 'originallyPublishedEndDate',
1163 'durationMin',
1164 'durationMax',
1165 'uuids',
1166 'search'
1167 ]),
1148 1168
1149 tagsOneOf: options.tagsOneOf, 1169 followerActorId: serverActor.id,
1150 tagsAllOf: options.tagsAllOf, 1170 serverAccountId: serverActor.Account.id
1151
1152 user: options.user,
1153 filter: options.filter,
1154
1155 start: options.start,
1156 count: options.count,
1157 sort: options.sort,
1158
1159 startDate: options.startDate,
1160 endDate: options.endDate,
1161
1162 originallyPublishedStartDate: options.originallyPublishedStartDate,
1163 originallyPublishedEndDate: options.originallyPublishedEndDate,
1164
1165 durationMin: options.durationMin,
1166 durationMax: options.durationMax,
1167
1168 search: options.search
1169 } 1171 }
1170 1172
1171 return VideoModel.getAvailableForApi(queryOptions) 1173 return VideoModel.getAvailableForApi(queryOptions)
@@ -1579,11 +1581,11 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1579 } 1581 }
1580 1582
1581 getWatchStaticPath () { 1583 getWatchStaticPath () {
1582 return '/w/' + this.uuid 1584 return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
1583 } 1585 }
1584 1586
1585 getEmbedStaticPath () { 1587 getEmbedStaticPath () {
1586 return '/videos/embed/' + this.uuid 1588 return buildVideoEmbedPath(this)
1587 } 1589 }
1588 1590
1589 getMiniatureStaticPath () { 1591 getMiniatureStaticPath () {
@@ -1670,10 +1672,13 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1670 .concat(toAdd) 1672 .concat(toAdd)
1671 } 1673 }
1672 1674
1673 removeFile (videoFile: MVideoFile, isRedundancy = false) { 1675 removeFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) {
1674 const filePath = getVideoFilePath(this, videoFile, isRedundancy) 1676 const filePath = getVideoFilePath(this, videoFile, isRedundancy)
1675 return remove(filePath) 1677
1676 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err })) 1678 const promises: Promise<any>[] = [ remove(filePath) ]
1679 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1680
1681 return Promise.all(promises)
1677 } 1682 }
1678 1683
1679 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) { 1684 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {