aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models/video
diff options
context:
space:
mode:
Diffstat (limited to 'server/models/video')
-rw-r--r--server/models/video/video-channel.ts78
-rw-r--r--server/models/video/video-format-utils.ts36
-rw-r--r--server/models/video/video-playlist-element.ts231
-rw-r--r--server/models/video/video-playlist.ts381
-rw-r--r--server/models/video/video.ts127
5 files changed, 754 insertions, 99 deletions
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'
19import { ActivityPubActor } from '../../../shared/models/activitypub' 19import { ActivityPubActor } from '../../../shared/models/activitypub'
20import { VideoChannel } from '../../../shared/models/videos' 20import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
21import { 21import {
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'
26import { sendDeleteActor } from '../../lib/activitypub/send' 26import { sendDeleteActor } from '../../lib/activitypub/send'
27import { AccountModel } from '../account/account' 27import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account'
28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' 28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
29import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 29import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
30import { VideoModel } from './video' 30import { VideoModel } from './video'
31import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' 31import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
32import { ServerModel } from '../server/server' 32import { ServerModel } from '../server/server'
33import { DefineIndexesOptions } from 'sequelize' 33import { DefineIndexesOptions } from 'sequelize'
34import { AvatarModel } from '../avatar/avatar'
35import { 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
36const indexes: DefineIndexesOptions[] = [ 38const indexes: DefineIndexesOptions[] = [
@@ -44,11 +46,12 @@ const indexes: DefineIndexesOptions[] = [
44 } 46 }
45] 47]
46 48
47enum ScopeNames { 49export 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
54type AvailableForListOptions = { 57type 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}
31function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video { 32function 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 @@
1import {
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'
16import { VideoModel } from './video'
17import { VideoPlaylistModel } from './video-playlist'
18import * as Sequelize from 'sequelize'
19import { getSort, throwIfNotValid } from '../utils'
20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
21import { CONSTRAINTS_FIELDS } from '../../initializers'
22import { 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})
47export 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 @@
1import {
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'
18import * as Sequelize from 'sequelize'
19import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
20import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, throwIfNotValid } from '../utils'
21import {
22 isVideoPlaylistDescriptionValid,
23 isVideoPlaylistNameValid,
24 isVideoPlaylistPrivacyValid
25} from '../../helpers/custom-validators/video-playlists'
26import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
27import { CONFIG, CONSTRAINTS_FIELDS, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers'
28import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
29import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
30import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
31import { join } from 'path'
32import { VideoPlaylistElementModel } from './video-playlist-element'
33import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
34import { activityPubCollectionPagination } from '../../helpers/activitypub'
35import { remove } from 'fs-extra'
36import { logger } from '../../helpers/logger'
37
38enum 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
44type 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})
149export 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'
70import { ActorModel } from '../activitypub/actor' 72import { ActorModel } from '../activitypub/actor'
71import { AvatarModel } from '../avatar/avatar' 73import { AvatarModel } from '../avatar/avatar'
72import { ServerModel } from '../server/server' 74import { ServerModel } from '../server/server'
73import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' 75import {
76 buildBlockedAccountSQL,
77 buildTrigramSearchIndex,
78 buildWhereIdOrUUID,
79 createSimilarityAttribute,
80 getVideoSort,
81 throwIfNotValid
82} from '../utils'
74import { TagModel } from './tag' 83import { TagModel } from './tag'
75import { VideoAbuseModel } from './video-abuse' 84import { VideoAbuseModel } from './video-abuse'
76import { VideoChannelModel } from './video-channel' 85import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel'
77import { VideoCommentModel } from './video-comment' 86import { VideoCommentModel } from './video-comment'
78import { VideoFileModel } from './video-file' 87import { VideoFileModel } from './video-file'
79import { VideoShareModel } from './video-share' 88import { 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'
94import * as validator from 'validator'
95import { UserVideoHistoryModel } from '../account/user-video-history' 103import { UserVideoHistoryModel } from '../account/user-video-history'
96import { UserModel } from '../account/user' 104import { UserModel } from '../account/user'
97import { VideoImportModel } from './video-import' 105import { VideoImportModel } from './video-import'
98import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 106import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
107import { 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
101const indexes: Sequelize.DefineIndexesOptions[] = [ 110const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -175,6 +184,9 @@ export enum ScopeNames {
175 184
176type ForAPIOptions = { 185type 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 }