diff options
author | Chocobozzz <me@florianbigard.com> | 2019-02-26 10:55:40 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2019-03-18 11:17:59 +0100 |
commit | 418d092afa81e2c8fe8ac6838fc4b5eb0af6a782 (patch) | |
tree | 5e9bc5604fd5d66a006cfebb7acdbdd5486e5d1e /server/models | |
parent | b427febb4d5cebf03b815bca2c59af6e82491569 (diff) | |
download | PeerTube-418d092afa81e2c8fe8ac6838fc4b5eb0af6a782.tar.gz PeerTube-418d092afa81e2c8fe8ac6838fc4b5eb0af6a782.tar.zst PeerTube-418d092afa81e2c8fe8ac6838fc4b5eb0af6a782.zip |
Playlist server API
Diffstat (limited to 'server/models')
-rw-r--r-- | server/models/account/account.ts | 60 | ||||
-rw-r--r-- | server/models/activitypub/actor-follow.ts | 2 | ||||
-rw-r--r-- | server/models/activitypub/actor.ts | 5 | ||||
-rw-r--r-- | server/models/utils.ts | 21 | ||||
-rw-r--r-- | server/models/video/video-channel.ts | 78 | ||||
-rw-r--r-- | server/models/video/video-format-utils.ts | 36 | ||||
-rw-r--r-- | server/models/video/video-playlist-element.ts | 231 | ||||
-rw-r--r-- | server/models/video/video-playlist.ts | 381 | ||||
-rw-r--r-- | server/models/video/video.ts | 127 |
9 files changed, 836 insertions, 105 deletions
diff --git a/server/models/account/account.ts b/server/models/account/account.ts index ee22d8528..3fb766c8a 100644 --- a/server/models/account/account.ts +++ b/server/models/account/account.ts | |||
@@ -10,11 +10,11 @@ import { | |||
10 | ForeignKey, | 10 | ForeignKey, |
11 | HasMany, | 11 | HasMany, |
12 | Is, | 12 | Is, |
13 | Model, | 13 | Model, Scopes, |
14 | Table, | 14 | Table, |
15 | UpdatedAt | 15 | UpdatedAt |
16 | } from 'sequelize-typescript' | 16 | } from 'sequelize-typescript' |
17 | import { Account } from '../../../shared/models/actors' | 17 | import { Account, AccountSummary } from '../../../shared/models/actors' |
18 | import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' | 18 | import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' |
19 | import { sendDeleteActor } from '../../lib/activitypub/send' | 19 | import { sendDeleteActor } from '../../lib/activitypub/send' |
20 | import { ActorModel } from '../activitypub/actor' | 20 | import { ActorModel } from '../activitypub/actor' |
@@ -25,6 +25,13 @@ import { VideoChannelModel } from '../video/video-channel' | |||
25 | import { VideoCommentModel } from '../video/video-comment' | 25 | import { VideoCommentModel } from '../video/video-comment' |
26 | import { UserModel } from './user' | 26 | import { UserModel } from './user' |
27 | import { CONFIG } from '../../initializers' | 27 | import { CONFIG } from '../../initializers' |
28 | import { AvatarModel } from '../avatar/avatar' | ||
29 | import { WhereOptions } from 'sequelize' | ||
30 | import { VideoPlaylistModel } from '../video/video-playlist' | ||
31 | |||
32 | export enum ScopeNames { | ||
33 | SUMMARY = 'SUMMARY' | ||
34 | } | ||
28 | 35 | ||
29 | @DefaultScope({ | 36 | @DefaultScope({ |
30 | include: [ | 37 | include: [ |
@@ -34,6 +41,32 @@ import { CONFIG } from '../../initializers' | |||
34 | } | 41 | } |
35 | ] | 42 | ] |
36 | }) | 43 | }) |
44 | @Scopes({ | ||
45 | [ ScopeNames.SUMMARY ]: (whereActor?: WhereOptions<ActorModel>) => { | ||
46 | return { | ||
47 | attributes: [ 'id', 'name' ], | ||
48 | include: [ | ||
49 | { | ||
50 | attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], | ||
51 | model: ActorModel.unscoped(), | ||
52 | required: true, | ||
53 | where: whereActor, | ||
54 | include: [ | ||
55 | { | ||
56 | attributes: [ 'host' ], | ||
57 | model: ServerModel.unscoped(), | ||
58 | required: false | ||
59 | }, | ||
60 | { | ||
61 | model: AvatarModel.unscoped(), | ||
62 | required: false | ||
63 | } | ||
64 | ] | ||
65 | } | ||
66 | ] | ||
67 | } | ||
68 | } | ||
69 | }) | ||
37 | @Table({ | 70 | @Table({ |
38 | tableName: 'account', | 71 | tableName: 'account', |
39 | indexes: [ | 72 | indexes: [ |
@@ -112,6 +145,15 @@ export class AccountModel extends Model<AccountModel> { | |||
112 | }) | 145 | }) |
113 | VideoChannels: VideoChannelModel[] | 146 | VideoChannels: VideoChannelModel[] |
114 | 147 | ||
148 | @HasMany(() => VideoPlaylistModel, { | ||
149 | foreignKey: { | ||
150 | allowNull: false | ||
151 | }, | ||
152 | onDelete: 'cascade', | ||
153 | hooks: true | ||
154 | }) | ||
155 | VideoPlaylists: VideoPlaylistModel[] | ||
156 | |||
115 | @HasMany(() => VideoCommentModel, { | 157 | @HasMany(() => VideoCommentModel, { |
116 | foreignKey: { | 158 | foreignKey: { |
117 | allowNull: false | 159 | allowNull: false |
@@ -285,6 +327,20 @@ export class AccountModel extends Model<AccountModel> { | |||
285 | return Object.assign(actor, account) | 327 | return Object.assign(actor, account) |
286 | } | 328 | } |
287 | 329 | ||
330 | toFormattedSummaryJSON (): AccountSummary { | ||
331 | const actor = this.Actor.toFormattedJSON() | ||
332 | |||
333 | return { | ||
334 | id: this.id, | ||
335 | uuid: actor.uuid, | ||
336 | name: actor.name, | ||
337 | displayName: this.getDisplayName(), | ||
338 | url: actor.url, | ||
339 | host: actor.host, | ||
340 | avatar: actor.avatar | ||
341 | } | ||
342 | } | ||
343 | |||
288 | toActivityPubObject () { | 344 | toActivityPubObject () { |
289 | const obj = this.Actor.toActivityPubObject(this.name, 'Account') | 345 | const obj = this.Actor.toActivityPubObject(this.name, 'Account') |
290 | 346 | ||
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts index 796e07a42..e3eeb7dae 100644 --- a/server/models/activitypub/actor-follow.ts +++ b/server/models/activitypub/actor-follow.ts | |||
@@ -407,7 +407,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
407 | }) | 407 | }) |
408 | } | 408 | } |
409 | 409 | ||
410 | static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { | 410 | static listAcceptedFollowerUrlsForAP (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { |
411 | return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count) | 411 | return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count) |
412 | } | 412 | } |
413 | 413 | ||
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 49f82023b..2fceb21dd 100644 --- a/server/models/activitypub/actor.ts +++ b/server/models/activitypub/actor.ts | |||
@@ -444,6 +444,7 @@ export class ActorModel extends Model<ActorModel> { | |||
444 | id: this.url, | 444 | id: this.url, |
445 | following: this.getFollowingUrl(), | 445 | following: this.getFollowingUrl(), |
446 | followers: this.getFollowersUrl(), | 446 | followers: this.getFollowersUrl(), |
447 | playlists: this.getPlaylistsUrl(), | ||
447 | inbox: this.inboxUrl, | 448 | inbox: this.inboxUrl, |
448 | outbox: this.outboxUrl, | 449 | outbox: this.outboxUrl, |
449 | preferredUsername: this.preferredUsername, | 450 | preferredUsername: this.preferredUsername, |
@@ -494,6 +495,10 @@ export class ActorModel extends Model<ActorModel> { | |||
494 | return this.url + '/followers' | 495 | return this.url + '/followers' |
495 | } | 496 | } |
496 | 497 | ||
498 | getPlaylistsUrl () { | ||
499 | return this.url + '/playlists' | ||
500 | } | ||
501 | |||
497 | getPublicKeyUrl () { | 502 | getPublicKeyUrl () { |
498 | return this.url + '#main-key' | 503 | return this.url + '#main-key' |
499 | } | 504 | } |
diff --git a/server/models/utils.ts b/server/models/utils.ts index 5b4093aec..4ebd07dab 100644 --- a/server/models/utils.ts +++ b/server/models/utils.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | import { Sequelize } from 'sequelize-typescript' | 1 | import { Sequelize } from 'sequelize-typescript' |
2 | import * as validator from 'validator' | ||
2 | 3 | ||
3 | type SortType = { sortModel: any, sortValue: string } | 4 | type SortType = { sortModel: any, sortValue: string } |
4 | 5 | ||
@@ -74,13 +75,25 @@ function buildBlockedAccountSQL (serverAccountId: number, userAccountId?: number | |||
74 | 75 | ||
75 | const blockerIdsString = blockerIds.join(', ') | 76 | const blockerIdsString = blockerIds.join(', ') |
76 | 77 | ||
77 | const query = 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' + | 78 | return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' + |
78 | ' UNION ALL ' + | 79 | ' UNION ALL ' + |
79 | 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' + | 80 | 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' + |
80 | 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' + | 81 | 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' + |
81 | 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')' | 82 | 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')' |
83 | } | ||
84 | |||
85 | function buildServerIdsFollowedBy (actorId: any) { | ||
86 | const actorIdNumber = parseInt(actorId + '', 10) | ||
87 | |||
88 | return '(' + | ||
89 | 'SELECT "actor"."serverId" FROM "actorFollow" ' + | ||
90 | 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' + | ||
91 | 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + | ||
92 | ')' | ||
93 | } | ||
82 | 94 | ||
83 | return query | 95 | function buildWhereIdOrUUID (id: number | string) { |
96 | return validator.isInt('' + id) ? { id } : { uuid: id } | ||
84 | } | 97 | } |
85 | 98 | ||
86 | // --------------------------------------------------------------------------- | 99 | // --------------------------------------------------------------------------- |
@@ -93,7 +106,9 @@ export { | |||
93 | getSortOnModel, | 106 | getSortOnModel, |
94 | createSimilarityAttribute, | 107 | createSimilarityAttribute, |
95 | throwIfNotValid, | 108 | throwIfNotValid, |
96 | buildTrigramSearchIndex | 109 | buildServerIdsFollowedBy, |
110 | buildTrigramSearchIndex, | ||
111 | buildWhereIdOrUUID | ||
97 | } | 112 | } |
98 | 113 | ||
99 | // --------------------------------------------------------------------------- | 114 | // --------------------------------------------------------------------------- |
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index 2426b3de6..112abf8cf 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts | |||
@@ -8,7 +8,7 @@ import { | |||
8 | Default, | 8 | Default, |
9 | DefaultScope, | 9 | DefaultScope, |
10 | ForeignKey, | 10 | ForeignKey, |
11 | HasMany, | 11 | HasMany, IFindOptions, |
12 | Is, | 12 | Is, |
13 | Model, | 13 | Model, |
14 | Scopes, | 14 | Scopes, |
@@ -17,20 +17,22 @@ import { | |||
17 | UpdatedAt | 17 | UpdatedAt |
18 | } from 'sequelize-typescript' | 18 | } from 'sequelize-typescript' |
19 | import { ActivityPubActor } from '../../../shared/models/activitypub' | 19 | import { ActivityPubActor } from '../../../shared/models/activitypub' |
20 | import { VideoChannel } from '../../../shared/models/videos' | 20 | import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos' |
21 | import { | 21 | import { |
22 | isVideoChannelDescriptionValid, | 22 | isVideoChannelDescriptionValid, |
23 | isVideoChannelNameValid, | 23 | isVideoChannelNameValid, |
24 | isVideoChannelSupportValid | 24 | isVideoChannelSupportValid |
25 | } from '../../helpers/custom-validators/video-channels' | 25 | } from '../../helpers/custom-validators/video-channels' |
26 | import { sendDeleteActor } from '../../lib/activitypub/send' | 26 | import { sendDeleteActor } from '../../lib/activitypub/send' |
27 | import { AccountModel } from '../account/account' | 27 | import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account' |
28 | import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' | 28 | import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' |
29 | import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' | 29 | import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' |
30 | import { VideoModel } from './video' | 30 | import { VideoModel } from './video' |
31 | import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' | 31 | import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' |
32 | import { ServerModel } from '../server/server' | 32 | import { ServerModel } from '../server/server' |
33 | import { DefineIndexesOptions } from 'sequelize' | 33 | import { DefineIndexesOptions } from 'sequelize' |
34 | import { AvatarModel } from '../avatar/avatar' | ||
35 | import { VideoPlaylistModel } from './video-playlist' | ||
34 | 36 | ||
35 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation | 37 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation |
36 | const indexes: DefineIndexesOptions[] = [ | 38 | const indexes: DefineIndexesOptions[] = [ |
@@ -44,11 +46,12 @@ const indexes: DefineIndexesOptions[] = [ | |||
44 | } | 46 | } |
45 | ] | 47 | ] |
46 | 48 | ||
47 | enum ScopeNames { | 49 | export enum ScopeNames { |
48 | AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', | 50 | AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', |
49 | WITH_ACCOUNT = 'WITH_ACCOUNT', | 51 | WITH_ACCOUNT = 'WITH_ACCOUNT', |
50 | WITH_ACTOR = 'WITH_ACTOR', | 52 | WITH_ACTOR = 'WITH_ACTOR', |
51 | WITH_VIDEOS = 'WITH_VIDEOS' | 53 | WITH_VIDEOS = 'WITH_VIDEOS', |
54 | SUMMARY = 'SUMMARY' | ||
52 | } | 55 | } |
53 | 56 | ||
54 | type AvailableForListOptions = { | 57 | type AvailableForListOptions = { |
@@ -64,15 +67,41 @@ type AvailableForListOptions = { | |||
64 | ] | 67 | ] |
65 | }) | 68 | }) |
66 | @Scopes({ | 69 | @Scopes({ |
67 | [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { | 70 | [ScopeNames.SUMMARY]: (required: boolean, withAccount: boolean) => { |
68 | const actorIdNumber = parseInt(options.actorId + '', 10) | 71 | const base: IFindOptions<VideoChannelModel> = { |
72 | attributes: [ 'name', 'description', 'id' ], | ||
73 | include: [ | ||
74 | { | ||
75 | attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], | ||
76 | model: ActorModel.unscoped(), | ||
77 | required: true, | ||
78 | include: [ | ||
79 | { | ||
80 | attributes: [ 'host' ], | ||
81 | model: ServerModel.unscoped(), | ||
82 | required: false | ||
83 | }, | ||
84 | { | ||
85 | model: AvatarModel.unscoped(), | ||
86 | required: false | ||
87 | } | ||
88 | ] | ||
89 | } | ||
90 | ] | ||
91 | } | ||
92 | |||
93 | if (withAccount === true) { | ||
94 | base.include.push({ | ||
95 | model: AccountModel.scope(AccountModelScopeNames.SUMMARY), | ||
96 | required: true | ||
97 | }) | ||
98 | } | ||
69 | 99 | ||
100 | return base | ||
101 | }, | ||
102 | [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { | ||
70 | // Only list local channels OR channels that are on an instance followed by actorId | 103 | // Only list local channels OR channels that are on an instance followed by actorId |
71 | const inQueryInstanceFollow = '(' + | 104 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) |
72 | 'SELECT "actor"."serverId" FROM "actorFollow" ' + | ||
73 | 'INNER JOIN "actor" ON actor.id= "actorFollow"."targetActorId" ' + | ||
74 | 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + | ||
75 | ')' | ||
76 | 105 | ||
77 | return { | 106 | return { |
78 | include: [ | 107 | include: [ |
@@ -192,6 +221,15 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
192 | }) | 221 | }) |
193 | Videos: VideoModel[] | 222 | Videos: VideoModel[] |
194 | 223 | ||
224 | @HasMany(() => VideoPlaylistModel, { | ||
225 | foreignKey: { | ||
226 | allowNull: false | ||
227 | }, | ||
228 | onDelete: 'cascade', | ||
229 | hooks: true | ||
230 | }) | ||
231 | VideoPlaylists: VideoPlaylistModel[] | ||
232 | |||
195 | @BeforeDestroy | 233 | @BeforeDestroy |
196 | static async sendDeleteIfOwned (instance: VideoChannelModel, options) { | 234 | static async sendDeleteIfOwned (instance: VideoChannelModel, options) { |
197 | if (!instance.Actor) { | 235 | if (!instance.Actor) { |
@@ -460,6 +498,20 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
460 | return Object.assign(actor, videoChannel) | 498 | return Object.assign(actor, videoChannel) |
461 | } | 499 | } |
462 | 500 | ||
501 | toFormattedSummaryJSON (): VideoChannelSummary { | ||
502 | const actor = this.Actor.toFormattedJSON() | ||
503 | |||
504 | return { | ||
505 | id: this.id, | ||
506 | uuid: actor.uuid, | ||
507 | name: actor.name, | ||
508 | displayName: this.getDisplayName(), | ||
509 | url: actor.url, | ||
510 | host: actor.host, | ||
511 | avatar: actor.avatar | ||
512 | } | ||
513 | } | ||
514 | |||
463 | toActivityPubObject (): ActivityPubActor { | 515 | toActivityPubObject (): ActivityPubActor { |
464 | const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel') | 516 | const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel') |
465 | 517 | ||
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index a62335333..dc10fb9a2 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts | |||
@@ -26,12 +26,10 @@ export type VideoFormattingJSONOptions = { | |||
26 | waitTranscoding?: boolean, | 26 | waitTranscoding?: boolean, |
27 | scheduledUpdate?: boolean, | 27 | scheduledUpdate?: boolean, |
28 | blacklistInfo?: boolean | 28 | blacklistInfo?: boolean |
29 | playlistInfo?: boolean | ||
29 | } | 30 | } |
30 | } | 31 | } |
31 | function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video { | 32 | function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video { |
32 | const formattedAccount = video.VideoChannel.Account.toFormattedJSON() | ||
33 | const formattedVideoChannel = video.VideoChannel.toFormattedJSON() | ||
34 | |||
35 | const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined | 33 | const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined |
36 | 34 | ||
37 | const videoObject: Video = { | 35 | const videoObject: Video = { |
@@ -68,24 +66,9 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting | |||
68 | updatedAt: video.updatedAt, | 66 | updatedAt: video.updatedAt, |
69 | publishedAt: video.publishedAt, | 67 | publishedAt: video.publishedAt, |
70 | originallyPublishedAt: video.originallyPublishedAt, | 68 | originallyPublishedAt: video.originallyPublishedAt, |
71 | account: { | 69 | |
72 | id: formattedAccount.id, | 70 | account: video.VideoChannel.Account.toFormattedSummaryJSON(), |
73 | uuid: formattedAccount.uuid, | 71 | channel: video.VideoChannel.toFormattedSummaryJSON(), |
74 | name: formattedAccount.name, | ||
75 | displayName: formattedAccount.displayName, | ||
76 | url: formattedAccount.url, | ||
77 | host: formattedAccount.host, | ||
78 | avatar: formattedAccount.avatar | ||
79 | }, | ||
80 | channel: { | ||
81 | id: formattedVideoChannel.id, | ||
82 | uuid: formattedVideoChannel.uuid, | ||
83 | name: formattedVideoChannel.name, | ||
84 | displayName: formattedVideoChannel.displayName, | ||
85 | url: formattedVideoChannel.url, | ||
86 | host: formattedVideoChannel.host, | ||
87 | avatar: formattedVideoChannel.avatar | ||
88 | }, | ||
89 | 72 | ||
90 | userHistory: userHistory ? { | 73 | userHistory: userHistory ? { |
91 | currentTime: userHistory.currentTime | 74 | currentTime: userHistory.currentTime |
@@ -115,6 +98,17 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting | |||
115 | videoObject.blacklisted = !!video.VideoBlacklist | 98 | videoObject.blacklisted = !!video.VideoBlacklist |
116 | videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null | 99 | videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null |
117 | } | 100 | } |
101 | |||
102 | if (options.additionalAttributes.playlistInfo === true) { | ||
103 | // We filtered on a specific videoId/videoPlaylistId, that is unique | ||
104 | const playlistElement = video.VideoPlaylistElements[0] | ||
105 | |||
106 | videoObject.playlistElement = { | ||
107 | position: playlistElement.position, | ||
108 | startTimestamp: playlistElement.startTimestamp, | ||
109 | stopTimestamp: playlistElement.stopTimestamp | ||
110 | } | ||
111 | } | ||
118 | } | 112 | } |
119 | 113 | ||
120 | return videoObject | 114 | return videoObject |
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts new file mode 100644 index 000000000..d76149d12 --- /dev/null +++ b/server/models/video/video-playlist-element.ts | |||
@@ -0,0 +1,231 @@ | |||
1 | import { | ||
2 | AllowNull, | ||
3 | BelongsTo, | ||
4 | Column, | ||
5 | CreatedAt, | ||
6 | DataType, | ||
7 | Default, | ||
8 | ForeignKey, | ||
9 | Is, | ||
10 | IsInt, | ||
11 | Min, | ||
12 | Model, | ||
13 | Table, | ||
14 | UpdatedAt | ||
15 | } from 'sequelize-typescript' | ||
16 | import { VideoModel } from './video' | ||
17 | import { VideoPlaylistModel } from './video-playlist' | ||
18 | import * as Sequelize from 'sequelize' | ||
19 | import { getSort, throwIfNotValid } from '../utils' | ||
20 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
21 | import { CONSTRAINTS_FIELDS } from '../../initializers' | ||
22 | import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' | ||
23 | |||
24 | @Table({ | ||
25 | tableName: 'videoPlaylistElement', | ||
26 | indexes: [ | ||
27 | { | ||
28 | fields: [ 'videoPlaylistId' ] | ||
29 | }, | ||
30 | { | ||
31 | fields: [ 'videoId' ] | ||
32 | }, | ||
33 | { | ||
34 | fields: [ 'videoPlaylistId', 'videoId' ], | ||
35 | unique: true | ||
36 | }, | ||
37 | { | ||
38 | fields: [ 'videoPlaylistId', 'position' ], | ||
39 | unique: true | ||
40 | }, | ||
41 | { | ||
42 | fields: [ 'url' ], | ||
43 | unique: true | ||
44 | } | ||
45 | ] | ||
46 | }) | ||
47 | export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel> { | ||
48 | @CreatedAt | ||
49 | createdAt: Date | ||
50 | |||
51 | @UpdatedAt | ||
52 | updatedAt: Date | ||
53 | |||
54 | @AllowNull(false) | ||
55 | @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) | ||
56 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max)) | ||
57 | url: string | ||
58 | |||
59 | @AllowNull(false) | ||
60 | @Default(1) | ||
61 | @IsInt | ||
62 | @Min(1) | ||
63 | @Column | ||
64 | position: number | ||
65 | |||
66 | @AllowNull(true) | ||
67 | @IsInt | ||
68 | @Min(0) | ||
69 | @Column | ||
70 | startTimestamp: number | ||
71 | |||
72 | @AllowNull(true) | ||
73 | @IsInt | ||
74 | @Min(0) | ||
75 | @Column | ||
76 | stopTimestamp: number | ||
77 | |||
78 | @ForeignKey(() => VideoPlaylistModel) | ||
79 | @Column | ||
80 | videoPlaylistId: number | ||
81 | |||
82 | @BelongsTo(() => VideoPlaylistModel, { | ||
83 | foreignKey: { | ||
84 | allowNull: false | ||
85 | }, | ||
86 | onDelete: 'CASCADE' | ||
87 | }) | ||
88 | VideoPlaylist: VideoPlaylistModel | ||
89 | |||
90 | @ForeignKey(() => VideoModel) | ||
91 | @Column | ||
92 | videoId: number | ||
93 | |||
94 | @BelongsTo(() => VideoModel, { | ||
95 | foreignKey: { | ||
96 | allowNull: false | ||
97 | }, | ||
98 | onDelete: 'CASCADE' | ||
99 | }) | ||
100 | Video: VideoModel | ||
101 | |||
102 | static deleteAllOf (videoPlaylistId: number, transaction?: Sequelize.Transaction) { | ||
103 | const query = { | ||
104 | where: { | ||
105 | videoPlaylistId | ||
106 | }, | ||
107 | transaction | ||
108 | } | ||
109 | |||
110 | return VideoPlaylistElementModel.destroy(query) | ||
111 | } | ||
112 | |||
113 | static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number) { | ||
114 | const query = { | ||
115 | where: { | ||
116 | videoPlaylistId, | ||
117 | videoId | ||
118 | } | ||
119 | } | ||
120 | |||
121 | return VideoPlaylistElementModel.findOne(query) | ||
122 | } | ||
123 | |||
124 | static loadByPlaylistAndVideoForAP (playlistId: number | string, videoId: number | string) { | ||
125 | const playlistWhere = validator.isUUID('' + playlistId) ? { uuid: playlistId } : { id: playlistId } | ||
126 | const videoWhere = validator.isUUID('' + videoId) ? { uuid: videoId } : { id: videoId } | ||
127 | |||
128 | const query = { | ||
129 | include: [ | ||
130 | { | ||
131 | attributes: [ 'privacy' ], | ||
132 | model: VideoPlaylistModel.unscoped(), | ||
133 | where: playlistWhere | ||
134 | }, | ||
135 | { | ||
136 | attributes: [ 'url' ], | ||
137 | model: VideoModel.unscoped(), | ||
138 | where: videoWhere | ||
139 | } | ||
140 | ] | ||
141 | } | ||
142 | |||
143 | return VideoPlaylistElementModel.findOne(query) | ||
144 | } | ||
145 | |||
146 | static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number) { | ||
147 | const query = { | ||
148 | attributes: [ 'url' ], | ||
149 | offset: start, | ||
150 | limit: count, | ||
151 | order: getSort('position'), | ||
152 | where: { | ||
153 | videoPlaylistId | ||
154 | } | ||
155 | } | ||
156 | |||
157 | return VideoPlaylistElementModel | ||
158 | .findAndCountAll(query) | ||
159 | .then(({ rows, count }) => { | ||
160 | return { total: count, data: rows.map(e => e.url) } | ||
161 | }) | ||
162 | } | ||
163 | |||
164 | static getNextPositionOf (videoPlaylistId: number, transaction?: Sequelize.Transaction) { | ||
165 | const query = { | ||
166 | where: { | ||
167 | videoPlaylistId | ||
168 | }, | ||
169 | transaction | ||
170 | } | ||
171 | |||
172 | return VideoPlaylistElementModel.max('position', query) | ||
173 | .then(position => position ? position + 1 : 1) | ||
174 | } | ||
175 | |||
176 | static reassignPositionOf ( | ||
177 | videoPlaylistId: number, | ||
178 | firstPosition: number, | ||
179 | endPosition: number, | ||
180 | newPosition: number, | ||
181 | transaction?: Sequelize.Transaction | ||
182 | ) { | ||
183 | const query = { | ||
184 | where: { | ||
185 | videoPlaylistId, | ||
186 | position: { | ||
187 | [Sequelize.Op.gte]: firstPosition, | ||
188 | [Sequelize.Op.lte]: endPosition | ||
189 | } | ||
190 | }, | ||
191 | transaction | ||
192 | } | ||
193 | |||
194 | return VideoPlaylistElementModel.update({ position: Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`) }, query) | ||
195 | } | ||
196 | |||
197 | static increasePositionOf ( | ||
198 | videoPlaylistId: number, | ||
199 | fromPosition: number, | ||
200 | toPosition?: number, | ||
201 | by = 1, | ||
202 | transaction?: Sequelize.Transaction | ||
203 | ) { | ||
204 | const query = { | ||
205 | where: { | ||
206 | videoPlaylistId, | ||
207 | position: { | ||
208 | [Sequelize.Op.gte]: fromPosition | ||
209 | } | ||
210 | }, | ||
211 | transaction | ||
212 | } | ||
213 | |||
214 | return VideoPlaylistElementModel.increment({ position: by }, query) | ||
215 | } | ||
216 | |||
217 | toActivityPubObject (): PlaylistElementObject { | ||
218 | const base: PlaylistElementObject = { | ||
219 | id: this.url, | ||
220 | type: 'PlaylistElement', | ||
221 | |||
222 | url: this.Video.url, | ||
223 | position: this.position | ||
224 | } | ||
225 | |||
226 | if (this.startTimestamp) base.startTimestamp = this.startTimestamp | ||
227 | if (this.stopTimestamp) base.stopTimestamp = this.stopTimestamp | ||
228 | |||
229 | return base | ||
230 | } | ||
231 | } | ||
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts new file mode 100644 index 000000000..93b8c2f58 --- /dev/null +++ b/server/models/video/video-playlist.ts | |||
@@ -0,0 +1,381 @@ | |||
1 | import { | ||
2 | AllowNull, | ||
3 | BeforeDestroy, | ||
4 | BelongsTo, | ||
5 | Column, | ||
6 | CreatedAt, | ||
7 | DataType, | ||
8 | Default, | ||
9 | ForeignKey, | ||
10 | HasMany, | ||
11 | Is, | ||
12 | IsUUID, | ||
13 | Model, | ||
14 | Scopes, | ||
15 | Table, | ||
16 | UpdatedAt | ||
17 | } from 'sequelize-typescript' | ||
18 | import * as Sequelize from 'sequelize' | ||
19 | import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
20 | import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, throwIfNotValid } from '../utils' | ||
21 | import { | ||
22 | isVideoPlaylistDescriptionValid, | ||
23 | isVideoPlaylistNameValid, | ||
24 | isVideoPlaylistPrivacyValid | ||
25 | } from '../../helpers/custom-validators/video-playlists' | ||
26 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
27 | import { CONFIG, CONSTRAINTS_FIELDS, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers' | ||
28 | import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' | ||
29 | import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' | ||
30 | import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' | ||
31 | import { join } from 'path' | ||
32 | import { VideoPlaylistElementModel } from './video-playlist-element' | ||
33 | import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' | ||
34 | import { activityPubCollectionPagination } from '../../helpers/activitypub' | ||
35 | import { remove } from 'fs-extra' | ||
36 | import { logger } from '../../helpers/logger' | ||
37 | |||
38 | enum ScopeNames { | ||
39 | AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', | ||
40 | WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH', | ||
41 | WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL' | ||
42 | } | ||
43 | |||
44 | type AvailableForListOptions = { | ||
45 | followerActorId: number | ||
46 | accountId?: number, | ||
47 | videoChannelId?: number | ||
48 | privateAndUnlisted?: boolean | ||
49 | } | ||
50 | |||
51 | @Scopes({ | ||
52 | [ScopeNames.WITH_VIDEOS_LENGTH]: { | ||
53 | attributes: { | ||
54 | include: [ | ||
55 | [ | ||
56 | Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), | ||
57 | 'videosLength' | ||
58 | ] | ||
59 | ] | ||
60 | } | ||
61 | }, | ||
62 | [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: { | ||
63 | include: [ | ||
64 | { | ||
65 | model: () => AccountModel.scope(AccountScopeNames.SUMMARY), | ||
66 | required: true | ||
67 | }, | ||
68 | { | ||
69 | model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), | ||
70 | required: false | ||
71 | } | ||
72 | ] | ||
73 | }, | ||
74 | [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { | ||
75 | // Only list local playlists OR playlists that are on an instance followed by actorId | ||
76 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) | ||
77 | const actorWhere = { | ||
78 | [ Sequelize.Op.or ]: [ | ||
79 | { | ||
80 | serverId: null | ||
81 | }, | ||
82 | { | ||
83 | serverId: { | ||
84 | [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow) | ||
85 | } | ||
86 | } | ||
87 | ] | ||
88 | } | ||
89 | |||
90 | const whereAnd: any[] = [] | ||
91 | |||
92 | if (options.privateAndUnlisted !== true) { | ||
93 | whereAnd.push({ | ||
94 | privacy: VideoPlaylistPrivacy.PUBLIC | ||
95 | }) | ||
96 | } | ||
97 | |||
98 | if (options.accountId) { | ||
99 | whereAnd.push({ | ||
100 | ownerAccountId: options.accountId | ||
101 | }) | ||
102 | } | ||
103 | |||
104 | if (options.videoChannelId) { | ||
105 | whereAnd.push({ | ||
106 | videoChannelId: options.videoChannelId | ||
107 | }) | ||
108 | } | ||
109 | |||
110 | const where = { | ||
111 | [Sequelize.Op.and]: whereAnd | ||
112 | } | ||
113 | |||
114 | const accountScope = { | ||
115 | method: [ AccountScopeNames.SUMMARY, actorWhere ] | ||
116 | } | ||
117 | |||
118 | return { | ||
119 | where, | ||
120 | include: [ | ||
121 | { | ||
122 | model: AccountModel.scope(accountScope), | ||
123 | required: true | ||
124 | }, | ||
125 | { | ||
126 | model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), | ||
127 | required: false | ||
128 | } | ||
129 | ] | ||
130 | } | ||
131 | } | ||
132 | }) | ||
133 | |||
134 | @Table({ | ||
135 | tableName: 'videoPlaylist', | ||
136 | indexes: [ | ||
137 | { | ||
138 | fields: [ 'ownerAccountId' ] | ||
139 | }, | ||
140 | { | ||
141 | fields: [ 'videoChannelId' ] | ||
142 | }, | ||
143 | { | ||
144 | fields: [ 'url' ], | ||
145 | unique: true | ||
146 | } | ||
147 | ] | ||
148 | }) | ||
149 | export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | ||
150 | @CreatedAt | ||
151 | createdAt: Date | ||
152 | |||
153 | @UpdatedAt | ||
154 | updatedAt: Date | ||
155 | |||
156 | @AllowNull(false) | ||
157 | @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name')) | ||
158 | @Column | ||
159 | name: string | ||
160 | |||
161 | @AllowNull(true) | ||
162 | @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description')) | ||
163 | @Column | ||
164 | description: string | ||
165 | |||
166 | @AllowNull(false) | ||
167 | @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy')) | ||
168 | @Column | ||
169 | privacy: VideoPlaylistPrivacy | ||
170 | |||
171 | @AllowNull(false) | ||
172 | @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) | ||
173 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max)) | ||
174 | url: string | ||
175 | |||
176 | @AllowNull(false) | ||
177 | @Default(DataType.UUIDV4) | ||
178 | @IsUUID(4) | ||
179 | @Column(DataType.UUID) | ||
180 | uuid: string | ||
181 | |||
182 | @ForeignKey(() => AccountModel) | ||
183 | @Column | ||
184 | ownerAccountId: number | ||
185 | |||
186 | @BelongsTo(() => AccountModel, { | ||
187 | foreignKey: { | ||
188 | allowNull: false | ||
189 | }, | ||
190 | onDelete: 'CASCADE' | ||
191 | }) | ||
192 | OwnerAccount: AccountModel | ||
193 | |||
194 | @ForeignKey(() => VideoChannelModel) | ||
195 | @Column | ||
196 | videoChannelId: number | ||
197 | |||
198 | @BelongsTo(() => VideoChannelModel, { | ||
199 | foreignKey: { | ||
200 | allowNull: false | ||
201 | }, | ||
202 | onDelete: 'CASCADE' | ||
203 | }) | ||
204 | VideoChannel: VideoChannelModel | ||
205 | |||
206 | @HasMany(() => VideoPlaylistElementModel, { | ||
207 | foreignKey: { | ||
208 | name: 'videoPlaylistId', | ||
209 | allowNull: false | ||
210 | }, | ||
211 | onDelete: 'cascade' | ||
212 | }) | ||
213 | VideoPlaylistElements: VideoPlaylistElementModel[] | ||
214 | |||
215 | // Calculated field | ||
216 | videosLength?: number | ||
217 | |||
218 | @BeforeDestroy | ||
219 | static async removeFiles (instance: VideoPlaylistModel) { | ||
220 | logger.info('Removing files of video playlist %s.', instance.url) | ||
221 | |||
222 | return instance.removeThumbnail() | ||
223 | } | ||
224 | |||
225 | static listForApi (options: { | ||
226 | followerActorId: number | ||
227 | start: number, | ||
228 | count: number, | ||
229 | sort: string, | ||
230 | accountId?: number, | ||
231 | videoChannelId?: number, | ||
232 | privateAndUnlisted?: boolean | ||
233 | }) { | ||
234 | const query = { | ||
235 | offset: options.start, | ||
236 | limit: options.count, | ||
237 | order: getSort(options.sort) | ||
238 | } | ||
239 | |||
240 | const scopes = [ | ||
241 | { | ||
242 | method: [ | ||
243 | ScopeNames.AVAILABLE_FOR_LIST, | ||
244 | { | ||
245 | followerActorId: options.followerActorId, | ||
246 | accountId: options.accountId, | ||
247 | videoChannelId: options.videoChannelId, | ||
248 | privateAndUnlisted: options.privateAndUnlisted | ||
249 | } as AvailableForListOptions | ||
250 | ] | ||
251 | } as any, // FIXME: typings | ||
252 | ScopeNames.WITH_VIDEOS_LENGTH | ||
253 | ] | ||
254 | |||
255 | return VideoPlaylistModel | ||
256 | .scope(scopes) | ||
257 | .findAndCountAll(query) | ||
258 | .then(({ rows, count }) => { | ||
259 | return { total: count, data: rows } | ||
260 | }) | ||
261 | } | ||
262 | |||
263 | static listUrlsOfForAP (accountId: number, start: number, count: number) { | ||
264 | const query = { | ||
265 | attributes: [ 'url' ], | ||
266 | offset: start, | ||
267 | limit: count, | ||
268 | where: { | ||
269 | ownerAccountId: accountId | ||
270 | } | ||
271 | } | ||
272 | |||
273 | return VideoPlaylistModel.findAndCountAll(query) | ||
274 | .then(({ rows, count }) => { | ||
275 | return { total: count, data: rows.map(p => p.url) } | ||
276 | }) | ||
277 | } | ||
278 | |||
279 | static doesPlaylistExist (url: string) { | ||
280 | const query = { | ||
281 | attributes: [], | ||
282 | where: { | ||
283 | url | ||
284 | } | ||
285 | } | ||
286 | |||
287 | return VideoPlaylistModel | ||
288 | .findOne(query) | ||
289 | .then(e => !!e) | ||
290 | } | ||
291 | |||
292 | static load (id: number | string, transaction: Sequelize.Transaction) { | ||
293 | const where = buildWhereIdOrUUID(id) | ||
294 | |||
295 | const query = { | ||
296 | where, | ||
297 | transaction | ||
298 | } | ||
299 | |||
300 | return VideoPlaylistModel | ||
301 | .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ]) | ||
302 | .findOne(query) | ||
303 | } | ||
304 | |||
305 | static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { | ||
306 | return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' | ||
307 | } | ||
308 | |||
309 | getThumbnailName () { | ||
310 | const extension = '.jpg' | ||
311 | |||
312 | return 'playlist-' + this.uuid + extension | ||
313 | } | ||
314 | |||
315 | getThumbnailUrl () { | ||
316 | return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() | ||
317 | } | ||
318 | |||
319 | getThumbnailStaticPath () { | ||
320 | return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) | ||
321 | } | ||
322 | |||
323 | removeThumbnail () { | ||
324 | const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) | ||
325 | return remove(thumbnailPath) | ||
326 | .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err })) | ||
327 | } | ||
328 | |||
329 | isOwned () { | ||
330 | return this.OwnerAccount.isOwned() | ||
331 | } | ||
332 | |||
333 | toFormattedJSON (): VideoPlaylist { | ||
334 | return { | ||
335 | id: this.id, | ||
336 | uuid: this.uuid, | ||
337 | isLocal: this.isOwned(), | ||
338 | |||
339 | displayName: this.name, | ||
340 | description: this.description, | ||
341 | privacy: { | ||
342 | id: this.privacy, | ||
343 | label: VideoPlaylistModel.getPrivacyLabel(this.privacy) | ||
344 | }, | ||
345 | |||
346 | thumbnailPath: this.getThumbnailStaticPath(), | ||
347 | |||
348 | videosLength: this.videosLength, | ||
349 | |||
350 | createdAt: this.createdAt, | ||
351 | updatedAt: this.updatedAt, | ||
352 | |||
353 | ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(), | ||
354 | videoChannel: this.VideoChannel.toFormattedSummaryJSON() | ||
355 | } | ||
356 | } | ||
357 | |||
358 | toActivityPubObject (): Promise<PlaylistObject> { | ||
359 | const handler = (start: number, count: number) => { | ||
360 | return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count) | ||
361 | } | ||
362 | |||
363 | return activityPubCollectionPagination(this.url, handler, null) | ||
364 | .then(o => { | ||
365 | return Object.assign(o, { | ||
366 | type: 'Playlist' as 'Playlist', | ||
367 | name: this.name, | ||
368 | content: this.description, | ||
369 | uuid: this.uuid, | ||
370 | attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [], | ||
371 | icon: { | ||
372 | type: 'Image' as 'Image', | ||
373 | url: this.getThumbnailUrl(), | ||
374 | mediaType: 'image/jpeg' as 'image/jpeg', | ||
375 | width: THUMBNAILS_SIZE.width, | ||
376 | height: THUMBNAILS_SIZE.height | ||
377 | } | ||
378 | }) | ||
379 | }) | ||
380 | } | ||
381 | } | ||
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 4516b9c7b..7a102b058 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -40,7 +40,7 @@ import { | |||
40 | isVideoDurationValid, | 40 | isVideoDurationValid, |
41 | isVideoLanguageValid, | 41 | isVideoLanguageValid, |
42 | isVideoLicenceValid, | 42 | isVideoLicenceValid, |
43 | isVideoNameValid, isVideoOriginallyPublishedAtValid, | 43 | isVideoNameValid, |
44 | isVideoPrivacyValid, | 44 | isVideoPrivacyValid, |
45 | isVideoStateValid, | 45 | isVideoStateValid, |
46 | isVideoSupportValid | 46 | isVideoSupportValid |
@@ -52,7 +52,9 @@ import { | |||
52 | ACTIVITY_PUB, | 52 | ACTIVITY_PUB, |
53 | API_VERSION, | 53 | API_VERSION, |
54 | CONFIG, | 54 | CONFIG, |
55 | CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY, | 55 | CONSTRAINTS_FIELDS, |
56 | HLS_PLAYLIST_DIRECTORY, | ||
57 | HLS_REDUNDANCY_DIRECTORY, | ||
56 | PREVIEWS_SIZE, | 58 | PREVIEWS_SIZE, |
57 | REMOTE_SCHEME, | 59 | REMOTE_SCHEME, |
58 | STATIC_DOWNLOAD_PATHS, | 60 | STATIC_DOWNLOAD_PATHS, |
@@ -70,10 +72,17 @@ import { AccountVideoRateModel } from '../account/account-video-rate' | |||
70 | import { ActorModel } from '../activitypub/actor' | 72 | import { ActorModel } from '../activitypub/actor' |
71 | import { AvatarModel } from '../avatar/avatar' | 73 | import { AvatarModel } from '../avatar/avatar' |
72 | import { ServerModel } from '../server/server' | 74 | import { ServerModel } from '../server/server' |
73 | import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' | 75 | import { |
76 | buildBlockedAccountSQL, | ||
77 | buildTrigramSearchIndex, | ||
78 | buildWhereIdOrUUID, | ||
79 | createSimilarityAttribute, | ||
80 | getVideoSort, | ||
81 | throwIfNotValid | ||
82 | } from '../utils' | ||
74 | import { TagModel } from './tag' | 83 | import { TagModel } from './tag' |
75 | import { VideoAbuseModel } from './video-abuse' | 84 | import { VideoAbuseModel } from './video-abuse' |
76 | import { VideoChannelModel } from './video-channel' | 85 | import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel' |
77 | import { VideoCommentModel } from './video-comment' | 86 | import { VideoCommentModel } from './video-comment' |
78 | import { VideoFileModel } from './video-file' | 87 | import { VideoFileModel } from './video-file' |
79 | import { VideoShareModel } from './video-share' | 88 | import { VideoShareModel } from './video-share' |
@@ -91,11 +100,11 @@ import { | |||
91 | videoModelToFormattedDetailsJSON, | 100 | videoModelToFormattedDetailsJSON, |
92 | videoModelToFormattedJSON | 101 | videoModelToFormattedJSON |
93 | } from './video-format-utils' | 102 | } from './video-format-utils' |
94 | import * as validator from 'validator' | ||
95 | import { UserVideoHistoryModel } from '../account/user-video-history' | 103 | import { UserVideoHistoryModel } from '../account/user-video-history' |
96 | import { UserModel } from '../account/user' | 104 | import { UserModel } from '../account/user' |
97 | import { VideoImportModel } from './video-import' | 105 | import { VideoImportModel } from './video-import' |
98 | import { VideoStreamingPlaylistModel } from './video-streaming-playlist' | 106 | import { VideoStreamingPlaylistModel } from './video-streaming-playlist' |
107 | import { VideoPlaylistElementModel } from './video-playlist-element' | ||
99 | 108 | ||
100 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation | 109 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation |
101 | const indexes: Sequelize.DefineIndexesOptions[] = [ | 110 | const indexes: Sequelize.DefineIndexesOptions[] = [ |
@@ -175,6 +184,9 @@ export enum ScopeNames { | |||
175 | 184 | ||
176 | type ForAPIOptions = { | 185 | type ForAPIOptions = { |
177 | ids: number[] | 186 | ids: number[] |
187 | |||
188 | videoPlaylistId?: number | ||
189 | |||
178 | withFiles?: boolean | 190 | withFiles?: boolean |
179 | } | 191 | } |
180 | 192 | ||
@@ -182,6 +194,7 @@ type AvailableForListIDsOptions = { | |||
182 | serverAccountId: number | 194 | serverAccountId: number |
183 | followerActorId: number | 195 | followerActorId: number |
184 | includeLocalVideos: boolean | 196 | includeLocalVideos: boolean |
197 | |||
185 | filter?: VideoFilter | 198 | filter?: VideoFilter |
186 | categoryOneOf?: number[] | 199 | categoryOneOf?: number[] |
187 | nsfw?: boolean | 200 | nsfw?: boolean |
@@ -189,9 +202,14 @@ type AvailableForListIDsOptions = { | |||
189 | languageOneOf?: string[] | 202 | languageOneOf?: string[] |
190 | tagsOneOf?: string[] | 203 | tagsOneOf?: string[] |
191 | tagsAllOf?: string[] | 204 | tagsAllOf?: string[] |
205 | |||
192 | withFiles?: boolean | 206 | withFiles?: boolean |
207 | |||
193 | accountId?: number | 208 | accountId?: number |
194 | videoChannelId?: number | 209 | videoChannelId?: number |
210 | |||
211 | videoPlaylistId?: number | ||
212 | |||
195 | trendingDays?: number | 213 | trendingDays?: number |
196 | user?: UserModel, | 214 | user?: UserModel, |
197 | historyOfUser?: UserModel | 215 | historyOfUser?: UserModel |
@@ -199,62 +217,17 @@ type AvailableForListIDsOptions = { | |||
199 | 217 | ||
200 | @Scopes({ | 218 | @Scopes({ |
201 | [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => { | 219 | [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => { |
202 | const accountInclude = { | ||
203 | attributes: [ 'id', 'name' ], | ||
204 | model: AccountModel.unscoped(), | ||
205 | required: true, | ||
206 | include: [ | ||
207 | { | ||
208 | attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], | ||
209 | model: ActorModel.unscoped(), | ||
210 | required: true, | ||
211 | include: [ | ||
212 | { | ||
213 | attributes: [ 'host' ], | ||
214 | model: ServerModel.unscoped(), | ||
215 | required: false | ||
216 | }, | ||
217 | { | ||
218 | model: AvatarModel.unscoped(), | ||
219 | required: false | ||
220 | } | ||
221 | ] | ||
222 | } | ||
223 | ] | ||
224 | } | ||
225 | |||
226 | const videoChannelInclude = { | ||
227 | attributes: [ 'name', 'description', 'id' ], | ||
228 | model: VideoChannelModel.unscoped(), | ||
229 | required: true, | ||
230 | include: [ | ||
231 | { | ||
232 | attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], | ||
233 | model: ActorModel.unscoped(), | ||
234 | required: true, | ||
235 | include: [ | ||
236 | { | ||
237 | attributes: [ 'host' ], | ||
238 | model: ServerModel.unscoped(), | ||
239 | required: false | ||
240 | }, | ||
241 | { | ||
242 | model: AvatarModel.unscoped(), | ||
243 | required: false | ||
244 | } | ||
245 | ] | ||
246 | }, | ||
247 | accountInclude | ||
248 | ] | ||
249 | } | ||
250 | |||
251 | const query: IFindOptions<VideoModel> = { | 220 | const query: IFindOptions<VideoModel> = { |
252 | where: { | 221 | where: { |
253 | id: { | 222 | id: { |
254 | [ Sequelize.Op.any ]: options.ids | 223 | [ Sequelize.Op.any ]: options.ids |
255 | } | 224 | } |
256 | }, | 225 | }, |
257 | include: [ videoChannelInclude ] | 226 | include: [ |
227 | { | ||
228 | model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY) | ||
229 | } | ||
230 | ] | ||
258 | } | 231 | } |
259 | 232 | ||
260 | if (options.withFiles === true) { | 233 | if (options.withFiles === true) { |
@@ -264,6 +237,13 @@ type AvailableForListIDsOptions = { | |||
264 | }) | 237 | }) |
265 | } | 238 | } |
266 | 239 | ||
240 | if (options.videoPlaylistId) { | ||
241 | query.include.push({ | ||
242 | model: VideoPlaylistElementModel.unscoped(), | ||
243 | required: true | ||
244 | }) | ||
245 | } | ||
246 | |||
267 | return query | 247 | return query |
268 | }, | 248 | }, |
269 | [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { | 249 | [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { |
@@ -315,6 +295,17 @@ type AvailableForListIDsOptions = { | |||
315 | Object.assign(query.where, privacyWhere) | 295 | Object.assign(query.where, privacyWhere) |
316 | } | 296 | } |
317 | 297 | ||
298 | if (options.videoPlaylistId) { | ||
299 | query.include.push({ | ||
300 | attributes: [], | ||
301 | model: VideoPlaylistElementModel.unscoped(), | ||
302 | required: true, | ||
303 | where: { | ||
304 | videoPlaylistId: options.videoPlaylistId | ||
305 | } | ||
306 | }) | ||
307 | } | ||
308 | |||
318 | if (options.filter || options.accountId || options.videoChannelId) { | 309 | if (options.filter || options.accountId || options.videoChannelId) { |
319 | const videoChannelInclude: IIncludeOptions = { | 310 | const videoChannelInclude: IIncludeOptions = { |
320 | attributes: [], | 311 | attributes: [], |
@@ -772,6 +763,15 @@ export class VideoModel extends Model<VideoModel> { | |||
772 | }) | 763 | }) |
773 | Tags: TagModel[] | 764 | Tags: TagModel[] |
774 | 765 | ||
766 | @HasMany(() => VideoPlaylistElementModel, { | ||
767 | foreignKey: { | ||
768 | name: 'videoId', | ||
769 | allowNull: false | ||
770 | }, | ||
771 | onDelete: 'cascade' | ||
772 | }) | ||
773 | VideoPlaylistElements: VideoPlaylistElementModel[] | ||
774 | |||
775 | @HasMany(() => VideoAbuseModel, { | 775 | @HasMany(() => VideoAbuseModel, { |
776 | foreignKey: { | 776 | foreignKey: { |
777 | name: 'videoId', | 777 | name: 'videoId', |
@@ -1118,6 +1118,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1118 | accountId?: number, | 1118 | accountId?: number, |
1119 | videoChannelId?: number, | 1119 | videoChannelId?: number, |
1120 | followerActorId?: number | 1120 | followerActorId?: number |
1121 | videoPlaylistId?: number, | ||
1121 | trendingDays?: number, | 1122 | trendingDays?: number, |
1122 | user?: UserModel, | 1123 | user?: UserModel, |
1123 | historyOfUser?: UserModel | 1124 | historyOfUser?: UserModel |
@@ -1157,6 +1158,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1157 | withFiles: options.withFiles, | 1158 | withFiles: options.withFiles, |
1158 | accountId: options.accountId, | 1159 | accountId: options.accountId, |
1159 | videoChannelId: options.videoChannelId, | 1160 | videoChannelId: options.videoChannelId, |
1161 | videoPlaylistId: options.videoPlaylistId, | ||
1160 | includeLocalVideos: options.includeLocalVideos, | 1162 | includeLocalVideos: options.includeLocalVideos, |
1161 | user: options.user, | 1163 | user: options.user, |
1162 | historyOfUser: options.historyOfUser, | 1164 | historyOfUser: options.historyOfUser, |
@@ -1280,7 +1282,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1280 | } | 1282 | } |
1281 | 1283 | ||
1282 | static load (id: number | string, t?: Sequelize.Transaction) { | 1284 | static load (id: number | string, t?: Sequelize.Transaction) { |
1283 | const where = VideoModel.buildWhereIdOrUUID(id) | 1285 | const where = buildWhereIdOrUUID(id) |
1284 | const options = { | 1286 | const options = { |
1285 | where, | 1287 | where, |
1286 | transaction: t | 1288 | transaction: t |
@@ -1290,7 +1292,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1290 | } | 1292 | } |
1291 | 1293 | ||
1292 | static loadWithRights (id: number | string, t?: Sequelize.Transaction) { | 1294 | static loadWithRights (id: number | string, t?: Sequelize.Transaction) { |
1293 | const where = VideoModel.buildWhereIdOrUUID(id) | 1295 | const where = buildWhereIdOrUUID(id) |
1294 | const options = { | 1296 | const options = { |
1295 | where, | 1297 | where, |
1296 | transaction: t | 1298 | transaction: t |
@@ -1300,7 +1302,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1300 | } | 1302 | } |
1301 | 1303 | ||
1302 | static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { | 1304 | static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { |
1303 | const where = VideoModel.buildWhereIdOrUUID(id) | 1305 | const where = buildWhereIdOrUUID(id) |
1304 | 1306 | ||
1305 | const options = { | 1307 | const options = { |
1306 | attributes: [ 'id' ], | 1308 | attributes: [ 'id' ], |
@@ -1353,7 +1355,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1353 | } | 1355 | } |
1354 | 1356 | ||
1355 | static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { | 1357 | static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { |
1356 | const where = VideoModel.buildWhereIdOrUUID(id) | 1358 | const where = buildWhereIdOrUUID(id) |
1357 | 1359 | ||
1358 | const options = { | 1360 | const options = { |
1359 | order: [ [ 'Tags', 'name', 'ASC' ] ], | 1361 | order: [ [ 'Tags', 'name', 'ASC' ] ], |
@@ -1380,7 +1382,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1380 | } | 1382 | } |
1381 | 1383 | ||
1382 | static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) { | 1384 | static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) { |
1383 | const where = VideoModel.buildWhereIdOrUUID(id) | 1385 | const where = buildWhereIdOrUUID(id) |
1384 | 1386 | ||
1385 | const options = { | 1387 | const options = { |
1386 | order: [ [ 'Tags', 'name', 'ASC' ] ], | 1388 | order: [ [ 'Tags', 'name', 'ASC' ] ], |
@@ -1582,10 +1584,6 @@ export class VideoModel extends Model<VideoModel> { | |||
1582 | return VIDEO_STATES[ id ] || 'Unknown' | 1584 | return VIDEO_STATES[ id ] || 'Unknown' |
1583 | } | 1585 | } |
1584 | 1586 | ||
1585 | static buildWhereIdOrUUID (id: number | string) { | ||
1586 | return validator.isInt('' + id) ? { id } : { uuid: id } | ||
1587 | } | ||
1588 | |||
1589 | getOriginalFile () { | 1587 | getOriginalFile () { |
1590 | if (Array.isArray(this.VideoFiles) === false) return undefined | 1588 | if (Array.isArray(this.VideoFiles) === false) return undefined |
1591 | 1589 | ||
@@ -1598,7 +1596,6 @@ export class VideoModel extends Model<VideoModel> { | |||
1598 | } | 1596 | } |
1599 | 1597 | ||
1600 | getThumbnailName () { | 1598 | getThumbnailName () { |
1601 | // We always have a copy of the thumbnail | ||
1602 | const extension = '.jpg' | 1599 | const extension = '.jpg' |
1603 | return this.uuid + extension | 1600 | return this.uuid + extension |
1604 | } | 1601 | } |