diff options
63 files changed, 2758 insertions, 226 deletions
@@ -53,7 +53,7 @@ if (errorMessage !== null) { | |||
53 | app.set('trust proxy', CONFIG.TRUST_PROXY) | 53 | app.set('trust proxy', CONFIG.TRUST_PROXY) |
54 | 54 | ||
55 | // Security middleware | 55 | // Security middleware |
56 | import { baseCSP } from './server/middlewares' | 56 | import { baseCSP } from './server/middlewares/csp' |
57 | 57 | ||
58 | if (CONFIG.CSP.ENABLED) { | 58 | if (CONFIG.CSP.ENABLED) { |
59 | app.use(baseCSP) | 59 | app.use(baseCSP) |
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 31c0a5fbd..59e6c8e9f 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts | |||
@@ -14,7 +14,7 @@ import { | |||
14 | videosCustomGetValidator, | 14 | videosCustomGetValidator, |
15 | videosShareValidator | 15 | videosShareValidator |
16 | } from '../../middlewares' | 16 | } from '../../middlewares' |
17 | import { getAccountVideoRateValidator, videoCommentGetValidator, videosGetValidator } from '../../middlewares/validators' | 17 | import { getAccountVideoRateValidator, videoCommentGetValidator } from '../../middlewares/validators' |
18 | import { AccountModel } from '../../models/account/account' | 18 | import { AccountModel } from '../../models/account/account' |
19 | import { ActorModel } from '../../models/activitypub/actor' | 19 | import { ActorModel } from '../../models/activitypub/actor' |
20 | import { ActorFollowModel } from '../../models/activitypub/actor-follow' | 20 | import { ActorFollowModel } from '../../models/activitypub/actor-follow' |
@@ -37,6 +37,10 @@ import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator } | |||
37 | import { getServerActor } from '../../helpers/utils' | 37 | import { getServerActor } from '../../helpers/utils' |
38 | import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' | 38 | import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' |
39 | import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike' | 39 | import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike' |
40 | import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists' | ||
41 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
42 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' | ||
43 | import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
40 | 44 | ||
41 | const activityPubClientRouter = express.Router() | 45 | const activityPubClientRouter = express.Router() |
42 | 46 | ||
@@ -52,6 +56,10 @@ activityPubClientRouter.get('/accounts?/:name/following', | |||
52 | executeIfActivityPub(asyncMiddleware(localAccountValidator)), | 56 | executeIfActivityPub(asyncMiddleware(localAccountValidator)), |
53 | executeIfActivityPub(asyncMiddleware(accountFollowingController)) | 57 | executeIfActivityPub(asyncMiddleware(accountFollowingController)) |
54 | ) | 58 | ) |
59 | activityPubClientRouter.get('/accounts?/:name/playlists', | ||
60 | executeIfActivityPub(asyncMiddleware(localAccountValidator)), | ||
61 | executeIfActivityPub(asyncMiddleware(accountPlaylistsController)) | ||
62 | ) | ||
55 | activityPubClientRouter.get('/accounts?/:name/likes/:videoId', | 63 | activityPubClientRouter.get('/accounts?/:name/likes/:videoId', |
56 | executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('like'))), | 64 | executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('like'))), |
57 | executeIfActivityPub(getAccountVideoRate('like')) | 65 | executeIfActivityPub(getAccountVideoRate('like')) |
@@ -121,6 +129,15 @@ activityPubClientRouter.get('/redundancy/video-playlists/:streamingPlaylistType/ | |||
121 | executeIfActivityPub(asyncMiddleware(videoRedundancyController)) | 129 | executeIfActivityPub(asyncMiddleware(videoRedundancyController)) |
122 | ) | 130 | ) |
123 | 131 | ||
132 | activityPubClientRouter.get('/video-playlists/:playlistId', | ||
133 | executeIfActivityPub(asyncMiddleware(videoPlaylistsGetValidator)), | ||
134 | executeIfActivityPub(asyncMiddleware(videoPlaylistController)) | ||
135 | ) | ||
136 | activityPubClientRouter.get('/video-playlists/:playlistId/:videoId', | ||
137 | executeIfActivityPub(asyncMiddleware(videoPlaylistElementAPGetValidator)), | ||
138 | executeIfActivityPub(asyncMiddleware(videoPlaylistElementController)) | ||
139 | ) | ||
140 | |||
124 | // --------------------------------------------------------------------------- | 141 | // --------------------------------------------------------------------------- |
125 | 142 | ||
126 | export { | 143 | export { |
@@ -129,26 +146,33 @@ export { | |||
129 | 146 | ||
130 | // --------------------------------------------------------------------------- | 147 | // --------------------------------------------------------------------------- |
131 | 148 | ||
132 | function accountController (req: express.Request, res: express.Response, next: express.NextFunction) { | 149 | function accountController (req: express.Request, res: express.Response) { |
133 | const account: AccountModel = res.locals.account | 150 | const account: AccountModel = res.locals.account |
134 | 151 | ||
135 | return activityPubResponse(activityPubContextify(account.toActivityPubObject()), res) | 152 | return activityPubResponse(activityPubContextify(account.toActivityPubObject()), res) |
136 | } | 153 | } |
137 | 154 | ||
138 | async function accountFollowersController (req: express.Request, res: express.Response, next: express.NextFunction) { | 155 | async function accountFollowersController (req: express.Request, res: express.Response) { |
139 | const account: AccountModel = res.locals.account | 156 | const account: AccountModel = res.locals.account |
140 | const activityPubResult = await actorFollowers(req, account.Actor) | 157 | const activityPubResult = await actorFollowers(req, account.Actor) |
141 | 158 | ||
142 | return activityPubResponse(activityPubContextify(activityPubResult), res) | 159 | return activityPubResponse(activityPubContextify(activityPubResult), res) |
143 | } | 160 | } |
144 | 161 | ||
145 | async function accountFollowingController (req: express.Request, res: express.Response, next: express.NextFunction) { | 162 | async function accountFollowingController (req: express.Request, res: express.Response) { |
146 | const account: AccountModel = res.locals.account | 163 | const account: AccountModel = res.locals.account |
147 | const activityPubResult = await actorFollowing(req, account.Actor) | 164 | const activityPubResult = await actorFollowing(req, account.Actor) |
148 | 165 | ||
149 | return activityPubResponse(activityPubContextify(activityPubResult), res) | 166 | return activityPubResponse(activityPubContextify(activityPubResult), res) |
150 | } | 167 | } |
151 | 168 | ||
169 | async function accountPlaylistsController (req: express.Request, res: express.Response) { | ||
170 | const account: AccountModel = res.locals.account | ||
171 | const activityPubResult = await actorPlaylists(req, account) | ||
172 | |||
173 | return activityPubResponse(activityPubContextify(activityPubResult), res) | ||
174 | } | ||
175 | |||
152 | function getAccountVideoRate (rateType: VideoRateType) { | 176 | function getAccountVideoRate (rateType: VideoRateType) { |
153 | return (req: express.Request, res: express.Response) => { | 177 | return (req: express.Request, res: express.Response) => { |
154 | const accountVideoRate: AccountVideoRateModel = res.locals.accountVideoRate | 178 | const accountVideoRate: AccountVideoRateModel = res.locals.accountVideoRate |
@@ -293,6 +317,23 @@ async function videoRedundancyController (req: express.Request, res: express.Res | |||
293 | return activityPubResponse(activityPubContextify(object), res) | 317 | return activityPubResponse(activityPubContextify(object), res) |
294 | } | 318 | } |
295 | 319 | ||
320 | async function videoPlaylistController (req: express.Request, res: express.Response) { | ||
321 | const playlist: VideoPlaylistModel = res.locals.videoPlaylist | ||
322 | |||
323 | const json = await playlist.toActivityPubObject() | ||
324 | const audience = getAudience(playlist.OwnerAccount.Actor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC) | ||
325 | const object = audiencify(json, audience) | ||
326 | |||
327 | return activityPubResponse(activityPubContextify(object), res) | ||
328 | } | ||
329 | |||
330 | async function videoPlaylistElementController (req: express.Request, res: express.Response) { | ||
331 | const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement | ||
332 | |||
333 | const json = videoPlaylistElement.toActivityPubObject() | ||
334 | return activityPubResponse(activityPubContextify(json), res) | ||
335 | } | ||
336 | |||
296 | // --------------------------------------------------------------------------- | 337 | // --------------------------------------------------------------------------- |
297 | 338 | ||
298 | async function actorFollowing (req: express.Request, actor: ActorModel) { | 339 | async function actorFollowing (req: express.Request, actor: ActorModel) { |
@@ -305,7 +346,15 @@ async function actorFollowing (req: express.Request, actor: ActorModel) { | |||
305 | 346 | ||
306 | async function actorFollowers (req: express.Request, actor: ActorModel) { | 347 | async function actorFollowers (req: express.Request, actor: ActorModel) { |
307 | const handler = (start: number, count: number) => { | 348 | const handler = (start: number, count: number) => { |
308 | return ActorFollowModel.listAcceptedFollowerUrlsForApi([ actor.id ], undefined, start, count) | 349 | return ActorFollowModel.listAcceptedFollowerUrlsForAP([ actor.id ], undefined, start, count) |
350 | } | ||
351 | |||
352 | return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page) | ||
353 | } | ||
354 | |||
355 | async function actorPlaylists (req: express.Request, account: AccountModel) { | ||
356 | const handler = (start: number, count: number) => { | ||
357 | return VideoPlaylistModel.listUrlsOfForAP(account.id, start, count) | ||
309 | } | 358 | } |
310 | 359 | ||
311 | return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page) | 360 | return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page) |
diff --git a/server/controllers/activitypub/outbox.ts b/server/controllers/activitypub/outbox.ts index bd0e4fe9d..e060affb2 100644 --- a/server/controllers/activitypub/outbox.ts +++ b/server/controllers/activitypub/outbox.ts | |||
@@ -32,7 +32,7 @@ export { | |||
32 | 32 | ||
33 | // --------------------------------------------------------------------------- | 33 | // --------------------------------------------------------------------------- |
34 | 34 | ||
35 | async function outboxController (req: express.Request, res: express.Response, next: express.NextFunction) { | 35 | async function outboxController (req: express.Request, res: express.Response) { |
36 | const accountOrVideoChannel: AccountModel | VideoChannelModel = res.locals.account || res.locals.videoChannel | 36 | const accountOrVideoChannel: AccountModel | VideoChannelModel = res.locals.account || res.locals.videoChannel |
37 | const actor = accountOrVideoChannel.Actor | 37 | const actor = accountOrVideoChannel.Actor |
38 | const actorOutboxUrl = actor.url + '/outbox' | 38 | const actorOutboxUrl = actor.url + '/outbox' |
diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index 8c0237203..03c831092 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts | |||
@@ -1,21 +1,23 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { getFormattedObjects } from '../../helpers/utils' | 2 | import { getFormattedObjects, getServerActor } from '../../helpers/utils' |
3 | import { | 3 | import { |
4 | asyncMiddleware, | 4 | asyncMiddleware, |
5 | commonVideosFiltersValidator, | 5 | commonVideosFiltersValidator, |
6 | listVideoAccountChannelsValidator, | ||
7 | optionalAuthenticate, | 6 | optionalAuthenticate, |
8 | paginationValidator, | 7 | paginationValidator, |
9 | setDefaultPagination, | 8 | setDefaultPagination, |
10 | setDefaultSort | 9 | setDefaultSort, |
10 | videoPlaylistsSortValidator | ||
11 | } from '../../middlewares' | 11 | } from '../../middlewares' |
12 | import { accountsNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators' | 12 | import { accountNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators' |
13 | import { AccountModel } from '../../models/account/account' | 13 | import { AccountModel } from '../../models/account/account' |
14 | import { VideoModel } from '../../models/video/video' | 14 | import { VideoModel } from '../../models/video/video' |
15 | import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' | 15 | import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' |
16 | import { VideoChannelModel } from '../../models/video/video-channel' | 16 | import { VideoChannelModel } from '../../models/video/video-channel' |
17 | import { JobQueue } from '../../lib/job-queue' | 17 | import { JobQueue } from '../../lib/job-queue' |
18 | import { logger } from '../../helpers/logger' | 18 | import { logger } from '../../helpers/logger' |
19 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
20 | import { UserModel } from '../../models/account/user' | ||
19 | 21 | ||
20 | const accountsRouter = express.Router() | 22 | const accountsRouter = express.Router() |
21 | 23 | ||
@@ -28,12 +30,12 @@ accountsRouter.get('/', | |||
28 | ) | 30 | ) |
29 | 31 | ||
30 | accountsRouter.get('/:accountName', | 32 | accountsRouter.get('/:accountName', |
31 | asyncMiddleware(accountsNameWithHostGetValidator), | 33 | asyncMiddleware(accountNameWithHostGetValidator), |
32 | getAccount | 34 | getAccount |
33 | ) | 35 | ) |
34 | 36 | ||
35 | accountsRouter.get('/:accountName/videos', | 37 | accountsRouter.get('/:accountName/videos', |
36 | asyncMiddleware(accountsNameWithHostGetValidator), | 38 | asyncMiddleware(accountNameWithHostGetValidator), |
37 | paginationValidator, | 39 | paginationValidator, |
38 | videosSortValidator, | 40 | videosSortValidator, |
39 | setDefaultSort, | 41 | setDefaultSort, |
@@ -44,8 +46,18 @@ accountsRouter.get('/:accountName/videos', | |||
44 | ) | 46 | ) |
45 | 47 | ||
46 | accountsRouter.get('/:accountName/video-channels', | 48 | accountsRouter.get('/:accountName/video-channels', |
47 | asyncMiddleware(listVideoAccountChannelsValidator), | 49 | asyncMiddleware(accountNameWithHostGetValidator), |
48 | asyncMiddleware(listVideoAccountChannels) | 50 | asyncMiddleware(listAccountChannels) |
51 | ) | ||
52 | |||
53 | accountsRouter.get('/:accountName/video-playlists', | ||
54 | optionalAuthenticate, | ||
55 | asyncMiddleware(accountNameWithHostGetValidator), | ||
56 | paginationValidator, | ||
57 | videoPlaylistsSortValidator, | ||
58 | setDefaultSort, | ||
59 | setDefaultPagination, | ||
60 | asyncMiddleware(listAccountPlaylists) | ||
49 | ) | 61 | ) |
50 | 62 | ||
51 | // --------------------------------------------------------------------------- | 63 | // --------------------------------------------------------------------------- |
@@ -56,7 +68,7 @@ export { | |||
56 | 68 | ||
57 | // --------------------------------------------------------------------------- | 69 | // --------------------------------------------------------------------------- |
58 | 70 | ||
59 | function getAccount (req: express.Request, res: express.Response, next: express.NextFunction) { | 71 | function getAccount (req: express.Request, res: express.Response) { |
60 | const account: AccountModel = res.locals.account | 72 | const account: AccountModel = res.locals.account |
61 | 73 | ||
62 | if (account.isOutdated()) { | 74 | if (account.isOutdated()) { |
@@ -67,19 +79,40 @@ function getAccount (req: express.Request, res: express.Response, next: express. | |||
67 | return res.json(account.toFormattedJSON()) | 79 | return res.json(account.toFormattedJSON()) |
68 | } | 80 | } |
69 | 81 | ||
70 | async function listAccounts (req: express.Request, res: express.Response, next: express.NextFunction) { | 82 | async function listAccounts (req: express.Request, res: express.Response) { |
71 | const resultList = await AccountModel.listForApi(req.query.start, req.query.count, req.query.sort) | 83 | const resultList = await AccountModel.listForApi(req.query.start, req.query.count, req.query.sort) |
72 | 84 | ||
73 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | 85 | return res.json(getFormattedObjects(resultList.data, resultList.total)) |
74 | } | 86 | } |
75 | 87 | ||
76 | async function listVideoAccountChannels (req: express.Request, res: express.Response, next: express.NextFunction) { | 88 | async function listAccountChannels (req: express.Request, res: express.Response) { |
77 | const resultList = await VideoChannelModel.listByAccount(res.locals.account.id) | 89 | const resultList = await VideoChannelModel.listByAccount(res.locals.account.id) |
78 | 90 | ||
79 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | 91 | return res.json(getFormattedObjects(resultList.data, resultList.total)) |
80 | } | 92 | } |
81 | 93 | ||
82 | async function listAccountVideos (req: express.Request, res: express.Response, next: express.NextFunction) { | 94 | async function listAccountPlaylists (req: express.Request, res: express.Response) { |
95 | const serverActor = await getServerActor() | ||
96 | |||
97 | // Allow users to see their private/unlisted video playlists | ||
98 | let privateAndUnlisted = false | ||
99 | if (res.locals.oauth && (res.locals.oauth.token.User as UserModel).Account.id === res.locals.account.id) { | ||
100 | privateAndUnlisted = true | ||
101 | } | ||
102 | |||
103 | const resultList = await VideoPlaylistModel.listForApi({ | ||
104 | followerActorId: serverActor.id, | ||
105 | start: req.query.start, | ||
106 | count: req.query.count, | ||
107 | sort: req.query.sort, | ||
108 | accountId: res.locals.account.id, | ||
109 | privateAndUnlisted | ||
110 | }) | ||
111 | |||
112 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | ||
113 | } | ||
114 | |||
115 | async function listAccountVideos (req: express.Request, res: express.Response) { | ||
83 | const account: AccountModel = res.locals.account | 116 | const account: AccountModel = res.locals.account |
84 | const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined | 117 | const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined |
85 | 118 | ||
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts index 8a58b5466..ed4b33dea 100644 --- a/server/controllers/api/index.ts +++ b/server/controllers/api/index.ts | |||
@@ -11,6 +11,7 @@ import { videoChannelRouter } from './video-channel' | |||
11 | import * as cors from 'cors' | 11 | import * as cors from 'cors' |
12 | import { searchRouter } from './search' | 12 | import { searchRouter } from './search' |
13 | import { overviewsRouter } from './overviews' | 13 | import { overviewsRouter } from './overviews' |
14 | import { videoPlaylistRouter } from './video-playlist' | ||
14 | 15 | ||
15 | const apiRouter = express.Router() | 16 | const apiRouter = express.Router() |
16 | 17 | ||
@@ -26,6 +27,7 @@ apiRouter.use('/config', configRouter) | |||
26 | apiRouter.use('/users', usersRouter) | 27 | apiRouter.use('/users', usersRouter) |
27 | apiRouter.use('/accounts', accountsRouter) | 28 | apiRouter.use('/accounts', accountsRouter) |
28 | apiRouter.use('/video-channels', videoChannelRouter) | 29 | apiRouter.use('/video-channels', videoChannelRouter) |
30 | apiRouter.use('/video-playlists', videoPlaylistRouter) | ||
29 | apiRouter.use('/videos', videosRouter) | 31 | apiRouter.use('/videos', videosRouter) |
30 | apiRouter.use('/jobs', jobsRouter) | 32 | apiRouter.use('/jobs', jobsRouter) |
31 | apiRouter.use('/search', searchRouter) | 33 | apiRouter.use('/search', searchRouter) |
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index db7602139..534cc8d7b 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts | |||
@@ -12,7 +12,8 @@ import { | |||
12 | videoChannelsAddValidator, | 12 | videoChannelsAddValidator, |
13 | videoChannelsRemoveValidator, | 13 | videoChannelsRemoveValidator, |
14 | videoChannelsSortValidator, | 14 | videoChannelsSortValidator, |
15 | videoChannelsUpdateValidator | 15 | videoChannelsUpdateValidator, |
16 | videoPlaylistsSortValidator | ||
16 | } from '../../middlewares' | 17 | } from '../../middlewares' |
17 | import { VideoChannelModel } from '../../models/video/video-channel' | 18 | import { VideoChannelModel } from '../../models/video/video-channel' |
18 | import { videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators' | 19 | import { videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators' |
@@ -31,6 +32,7 @@ import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '.. | |||
31 | import { resetSequelizeInstance } from '../../helpers/database-utils' | 32 | import { resetSequelizeInstance } from '../../helpers/database-utils' |
32 | import { UserModel } from '../../models/account/user' | 33 | import { UserModel } from '../../models/account/user' |
33 | import { JobQueue } from '../../lib/job-queue' | 34 | import { JobQueue } from '../../lib/job-queue' |
35 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
34 | 36 | ||
35 | const auditLogger = auditLoggerFactory('channels') | 37 | const auditLogger = auditLoggerFactory('channels') |
36 | const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR }) | 38 | const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR }) |
@@ -77,6 +79,15 @@ videoChannelRouter.get('/:nameWithHost', | |||
77 | asyncMiddleware(getVideoChannel) | 79 | asyncMiddleware(getVideoChannel) |
78 | ) | 80 | ) |
79 | 81 | ||
82 | videoChannelRouter.get('/:nameWithHost/video-playlists', | ||
83 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
84 | paginationValidator, | ||
85 | videoPlaylistsSortValidator, | ||
86 | setDefaultSort, | ||
87 | setDefaultPagination, | ||
88 | asyncMiddleware(listVideoChannelPlaylists) | ||
89 | ) | ||
90 | |||
80 | videoChannelRouter.get('/:nameWithHost/videos', | 91 | videoChannelRouter.get('/:nameWithHost/videos', |
81 | asyncMiddleware(videoChannelsNameWithHostValidator), | 92 | asyncMiddleware(videoChannelsNameWithHostValidator), |
82 | paginationValidator, | 93 | paginationValidator, |
@@ -206,6 +217,20 @@ async function getVideoChannel (req: express.Request, res: express.Response, nex | |||
206 | return res.json(videoChannelWithVideos.toFormattedJSON()) | 217 | return res.json(videoChannelWithVideos.toFormattedJSON()) |
207 | } | 218 | } |
208 | 219 | ||
220 | async function listVideoChannelPlaylists (req: express.Request, res: express.Response) { | ||
221 | const serverActor = await getServerActor() | ||
222 | |||
223 | const resultList = await VideoPlaylistModel.listForApi({ | ||
224 | followerActorId: serverActor.id, | ||
225 | start: req.query.start, | ||
226 | count: req.query.count, | ||
227 | sort: req.query.sort, | ||
228 | videoChannelId: res.locals.videoChannel.id | ||
229 | }) | ||
230 | |||
231 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | ||
232 | } | ||
233 | |||
209 | async function listVideoChannelVideos (req: express.Request, res: express.Response, next: express.NextFunction) { | 234 | async function listVideoChannelVideos (req: express.Request, res: express.Response, next: express.NextFunction) { |
210 | const videoChannelInstance: VideoChannelModel = res.locals.videoChannel | 235 | const videoChannelInstance: VideoChannelModel = res.locals.videoChannel |
211 | const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined | 236 | const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined |
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts new file mode 100644 index 000000000..709c58beb --- /dev/null +++ b/server/controllers/api/video-playlist.ts | |||
@@ -0,0 +1,415 @@ | |||
1 | import * as express from 'express' | ||
2 | import { getFormattedObjects, getServerActor } from '../../helpers/utils' | ||
3 | import { | ||
4 | asyncMiddleware, | ||
5 | asyncRetryTransactionMiddleware, | ||
6 | authenticate, | ||
7 | commonVideosFiltersValidator, | ||
8 | paginationValidator, | ||
9 | setDefaultPagination, | ||
10 | setDefaultSort | ||
11 | } from '../../middlewares' | ||
12 | import { VideoChannelModel } from '../../models/video/video-channel' | ||
13 | import { videoPlaylistsSortValidator } from '../../middlewares/validators' | ||
14 | import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' | ||
15 | import { CONFIG, MIMETYPES, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers' | ||
16 | import { logger } from '../../helpers/logger' | ||
17 | import { resetSequelizeInstance } from '../../helpers/database-utils' | ||
18 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
19 | import { | ||
20 | videoPlaylistsAddValidator, | ||
21 | videoPlaylistsAddVideoValidator, | ||
22 | videoPlaylistsDeleteValidator, | ||
23 | videoPlaylistsGetValidator, | ||
24 | videoPlaylistsReorderVideosValidator, | ||
25 | videoPlaylistsUpdateOrRemoveVideoValidator, | ||
26 | videoPlaylistsUpdateValidator | ||
27 | } from '../../middlewares/validators/videos/video-playlists' | ||
28 | import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model' | ||
29 | import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
30 | import { processImage } from '../../helpers/image-utils' | ||
31 | import { join } from 'path' | ||
32 | import { UserModel } from '../../models/account/user' | ||
33 | import { | ||
34 | getVideoPlaylistActivityPubUrl, | ||
35 | getVideoPlaylistElementActivityPubUrl, | ||
36 | sendCreateVideoPlaylist, | ||
37 | sendDeleteVideoPlaylist, | ||
38 | sendUpdateVideoPlaylist | ||
39 | } from '../../lib/activitypub' | ||
40 | import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model' | ||
41 | import { VideoModel } from '../../models/video/video' | ||
42 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' | ||
43 | import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model' | ||
44 | import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' | ||
45 | import { copy, pathExists } from 'fs-extra' | ||
46 | |||
47 | const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) | ||
48 | |||
49 | const videoPlaylistRouter = express.Router() | ||
50 | |||
51 | videoPlaylistRouter.get('/', | ||
52 | paginationValidator, | ||
53 | videoPlaylistsSortValidator, | ||
54 | setDefaultSort, | ||
55 | setDefaultPagination, | ||
56 | asyncMiddleware(listVideoPlaylists) | ||
57 | ) | ||
58 | |||
59 | videoPlaylistRouter.get('/:playlistId', | ||
60 | asyncMiddleware(videoPlaylistsGetValidator), | ||
61 | getVideoPlaylist | ||
62 | ) | ||
63 | |||
64 | videoPlaylistRouter.post('/', | ||
65 | authenticate, | ||
66 | reqThumbnailFile, | ||
67 | asyncMiddleware(videoPlaylistsAddValidator), | ||
68 | asyncRetryTransactionMiddleware(addVideoPlaylist) | ||
69 | ) | ||
70 | |||
71 | videoPlaylistRouter.put('/:playlistId', | ||
72 | authenticate, | ||
73 | reqThumbnailFile, | ||
74 | asyncMiddleware(videoPlaylistsUpdateValidator), | ||
75 | asyncRetryTransactionMiddleware(updateVideoPlaylist) | ||
76 | ) | ||
77 | |||
78 | videoPlaylistRouter.delete('/:playlistId', | ||
79 | authenticate, | ||
80 | asyncMiddleware(videoPlaylistsDeleteValidator), | ||
81 | asyncRetryTransactionMiddleware(removeVideoPlaylist) | ||
82 | ) | ||
83 | |||
84 | videoPlaylistRouter.get('/:playlistId/videos', | ||
85 | asyncMiddleware(videoPlaylistsGetValidator), | ||
86 | paginationValidator, | ||
87 | setDefaultPagination, | ||
88 | commonVideosFiltersValidator, | ||
89 | asyncMiddleware(getVideoPlaylistVideos) | ||
90 | ) | ||
91 | |||
92 | videoPlaylistRouter.post('/:playlistId/videos', | ||
93 | authenticate, | ||
94 | asyncMiddleware(videoPlaylistsAddVideoValidator), | ||
95 | asyncRetryTransactionMiddleware(addVideoInPlaylist) | ||
96 | ) | ||
97 | |||
98 | videoPlaylistRouter.put('/:playlistId/videos', | ||
99 | authenticate, | ||
100 | asyncMiddleware(videoPlaylistsReorderVideosValidator), | ||
101 | asyncRetryTransactionMiddleware(reorderVideosPlaylist) | ||
102 | ) | ||
103 | |||
104 | videoPlaylistRouter.put('/:playlistId/videos/:videoId', | ||
105 | authenticate, | ||
106 | asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator), | ||
107 | asyncRetryTransactionMiddleware(updateVideoPlaylistElement) | ||
108 | ) | ||
109 | |||
110 | videoPlaylistRouter.delete('/:playlistId/videos/:videoId', | ||
111 | authenticate, | ||
112 | asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator), | ||
113 | asyncRetryTransactionMiddleware(removeVideoFromPlaylist) | ||
114 | ) | ||
115 | |||
116 | // --------------------------------------------------------------------------- | ||
117 | |||
118 | export { | ||
119 | videoPlaylistRouter | ||
120 | } | ||
121 | |||
122 | // --------------------------------------------------------------------------- | ||
123 | |||
124 | async function listVideoPlaylists (req: express.Request, res: express.Response) { | ||
125 | const serverActor = await getServerActor() | ||
126 | const resultList = await VideoPlaylistModel.listForApi({ | ||
127 | followerActorId: serverActor.id, | ||
128 | start: req.query.start, | ||
129 | count: req.query.count, | ||
130 | sort: req.query.sort | ||
131 | }) | ||
132 | |||
133 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | ||
134 | } | ||
135 | |||
136 | function getVideoPlaylist (req: express.Request, res: express.Response) { | ||
137 | const videoPlaylist = res.locals.videoPlaylist as VideoPlaylistModel | ||
138 | |||
139 | return res.json(videoPlaylist.toFormattedJSON()) | ||
140 | } | ||
141 | |||
142 | async function addVideoPlaylist (req: express.Request, res: express.Response) { | ||
143 | const videoPlaylistInfo: VideoPlaylistCreate = req.body | ||
144 | const user: UserModel = res.locals.oauth.token.User | ||
145 | |||
146 | const videoPlaylist = new VideoPlaylistModel({ | ||
147 | name: videoPlaylistInfo.displayName, | ||
148 | description: videoPlaylistInfo.description, | ||
149 | privacy: videoPlaylistInfo.privacy || VideoPlaylistPrivacy.PRIVATE, | ||
150 | ownerAccountId: user.Account.id | ||
151 | }) | ||
152 | |||
153 | videoPlaylist.url = getVideoPlaylistActivityPubUrl(videoPlaylist) // We use the UUID, so set the URL after building the object | ||
154 | |||
155 | if (videoPlaylistInfo.videoChannelId !== undefined) { | ||
156 | const videoChannel = res.locals.videoChannel as VideoChannelModel | ||
157 | |||
158 | videoPlaylist.videoChannelId = videoChannel.id | ||
159 | videoPlaylist.VideoChannel = videoChannel | ||
160 | } | ||
161 | |||
162 | const thumbnailField = req.files['thumbnailfile'] | ||
163 | if (thumbnailField) { | ||
164 | const thumbnailPhysicalFile = thumbnailField[ 0 ] | ||
165 | await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()), THUMBNAILS_SIZE) | ||
166 | } | ||
167 | |||
168 | const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => { | ||
169 | const videoPlaylistCreated = await videoPlaylist.save({ transaction: t }) | ||
170 | |||
171 | await sendCreateVideoPlaylist(videoPlaylistCreated, t) | ||
172 | |||
173 | return videoPlaylistCreated | ||
174 | }) | ||
175 | |||
176 | logger.info('Video playlist with uuid %s created.', videoPlaylist.uuid) | ||
177 | |||
178 | return res.json({ | ||
179 | videoPlaylist: { | ||
180 | id: videoPlaylistCreated.id, | ||
181 | uuid: videoPlaylistCreated.uuid | ||
182 | } | ||
183 | }).end() | ||
184 | } | ||
185 | |||
186 | async function updateVideoPlaylist (req: express.Request, res: express.Response) { | ||
187 | const videoPlaylistInstance = res.locals.videoPlaylist as VideoPlaylistModel | ||
188 | const videoPlaylistFieldsSave = videoPlaylistInstance.toJSON() | ||
189 | const videoPlaylistInfoToUpdate = req.body as VideoPlaylistUpdate | ||
190 | const wasPrivatePlaylist = videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE | ||
191 | |||
192 | const thumbnailField = req.files['thumbnailfile'] | ||
193 | if (thumbnailField) { | ||
194 | const thumbnailPhysicalFile = thumbnailField[ 0 ] | ||
195 | await processImage( | ||
196 | thumbnailPhysicalFile, | ||
197 | join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylistInstance.getThumbnailName()), | ||
198 | THUMBNAILS_SIZE | ||
199 | ) | ||
200 | } | ||
201 | |||
202 | try { | ||
203 | await sequelizeTypescript.transaction(async t => { | ||
204 | const sequelizeOptions = { | ||
205 | transaction: t | ||
206 | } | ||
207 | |||
208 | if (videoPlaylistInfoToUpdate.videoChannelId !== undefined) { | ||
209 | if (videoPlaylistInfoToUpdate.videoChannelId === null) { | ||
210 | videoPlaylistInstance.videoChannelId = null | ||
211 | } else { | ||
212 | const videoChannel = res.locals.videoChannel as VideoChannelModel | ||
213 | |||
214 | videoPlaylistInstance.videoChannelId = videoChannel.id | ||
215 | } | ||
216 | } | ||
217 | |||
218 | if (videoPlaylistInfoToUpdate.displayName !== undefined) videoPlaylistInstance.name = videoPlaylistInfoToUpdate.displayName | ||
219 | if (videoPlaylistInfoToUpdate.description !== undefined) videoPlaylistInstance.description = videoPlaylistInfoToUpdate.description | ||
220 | |||
221 | if (videoPlaylistInfoToUpdate.privacy !== undefined) { | ||
222 | videoPlaylistInstance.privacy = parseInt(videoPlaylistInfoToUpdate.privacy.toString(), 10) | ||
223 | } | ||
224 | |||
225 | const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions) | ||
226 | |||
227 | const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE | ||
228 | |||
229 | if (isNewPlaylist) { | ||
230 | await sendCreateVideoPlaylist(playlistUpdated, t) | ||
231 | } else { | ||
232 | await sendUpdateVideoPlaylist(playlistUpdated, t) | ||
233 | } | ||
234 | |||
235 | logger.info('Video playlist %s updated.', videoPlaylistInstance.uuid) | ||
236 | |||
237 | return playlistUpdated | ||
238 | }) | ||
239 | } catch (err) { | ||
240 | logger.debug('Cannot update the video playlist.', { err }) | ||
241 | |||
242 | // Force fields we want to update | ||
243 | // If the transaction is retried, sequelize will think the object has not changed | ||
244 | // So it will skip the SQL request, even if the last one was ROLLBACKed! | ||
245 | resetSequelizeInstance(videoPlaylistInstance, videoPlaylistFieldsSave) | ||
246 | |||
247 | throw err | ||
248 | } | ||
249 | |||
250 | return res.type('json').status(204).end() | ||
251 | } | ||
252 | |||
253 | async function removeVideoPlaylist (req: express.Request, res: express.Response) { | ||
254 | const videoPlaylistInstance: VideoPlaylistModel = res.locals.videoPlaylist | ||
255 | |||
256 | await sequelizeTypescript.transaction(async t => { | ||
257 | await videoPlaylistInstance.destroy({ transaction: t }) | ||
258 | |||
259 | await sendDeleteVideoPlaylist(videoPlaylistInstance, t) | ||
260 | |||
261 | logger.info('Video playlist %s deleted.', videoPlaylistInstance.uuid) | ||
262 | }) | ||
263 | |||
264 | return res.type('json').status(204).end() | ||
265 | } | ||
266 | |||
267 | async function addVideoInPlaylist (req: express.Request, res: express.Response) { | ||
268 | const body: VideoPlaylistElementCreate = req.body | ||
269 | const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist | ||
270 | const video: VideoModel = res.locals.video | ||
271 | |||
272 | const playlistElement: VideoPlaylistElementModel = await sequelizeTypescript.transaction(async t => { | ||
273 | const position = await VideoPlaylistElementModel.getNextPositionOf(videoPlaylist.id, t) | ||
274 | |||
275 | const playlistElement = await VideoPlaylistElementModel.create({ | ||
276 | url: getVideoPlaylistElementActivityPubUrl(videoPlaylist, video), | ||
277 | position, | ||
278 | startTimestamp: body.startTimestamp || null, | ||
279 | stopTimestamp: body.stopTimestamp || null, | ||
280 | videoPlaylistId: videoPlaylist.id, | ||
281 | videoId: video.id | ||
282 | }, { transaction: t }) | ||
283 | |||
284 | // If the user did not set a thumbnail, automatically take the video thumbnail | ||
285 | if (playlistElement.position === 1) { | ||
286 | const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()) | ||
287 | |||
288 | if (await pathExists(playlistThumbnailPath) === false) { | ||
289 | const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()) | ||
290 | await copy(videoThumbnailPath, playlistThumbnailPath) | ||
291 | } | ||
292 | } | ||
293 | |||
294 | await sendUpdateVideoPlaylist(videoPlaylist, t) | ||
295 | |||
296 | return playlistElement | ||
297 | }) | ||
298 | |||
299 | logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position) | ||
300 | |||
301 | return res.json({ | ||
302 | videoPlaylistElement: { | ||
303 | id: playlistElement.id | ||
304 | } | ||
305 | }).end() | ||
306 | } | ||
307 | |||
308 | async function updateVideoPlaylistElement (req: express.Request, res: express.Response) { | ||
309 | const body: VideoPlaylistElementUpdate = req.body | ||
310 | const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist | ||
311 | const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement | ||
312 | |||
313 | const playlistElement: VideoPlaylistElementModel = await sequelizeTypescript.transaction(async t => { | ||
314 | if (body.startTimestamp !== undefined) videoPlaylistElement.startTimestamp = body.startTimestamp | ||
315 | if (body.stopTimestamp !== undefined) videoPlaylistElement.stopTimestamp = body.stopTimestamp | ||
316 | |||
317 | const element = await videoPlaylistElement.save({ transaction: t }) | ||
318 | |||
319 | await sendUpdateVideoPlaylist(videoPlaylist, t) | ||
320 | |||
321 | return element | ||
322 | }) | ||
323 | |||
324 | logger.info('Element of position %d of playlist %s updated.', playlistElement.position, videoPlaylist.uuid) | ||
325 | |||
326 | return res.type('json').status(204).end() | ||
327 | } | ||
328 | |||
329 | async function removeVideoFromPlaylist (req: express.Request, res: express.Response) { | ||
330 | const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement | ||
331 | const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist | ||
332 | const positionToDelete = videoPlaylistElement.position | ||
333 | |||
334 | await sequelizeTypescript.transaction(async t => { | ||
335 | await videoPlaylistElement.destroy({ transaction: t }) | ||
336 | |||
337 | // Decrease position of the next elements | ||
338 | await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t) | ||
339 | |||
340 | await sendUpdateVideoPlaylist(videoPlaylist, t) | ||
341 | |||
342 | logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid) | ||
343 | }) | ||
344 | |||
345 | return res.type('json').status(204).end() | ||
346 | } | ||
347 | |||
348 | async function reorderVideosPlaylist (req: express.Request, res: express.Response) { | ||
349 | const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist | ||
350 | |||
351 | const start: number = req.body.startPosition | ||
352 | const insertAfter: number = req.body.insertAfter | ||
353 | const reorderLength: number = req.body.reorderLength || 1 | ||
354 | |||
355 | if (start === insertAfter) { | ||
356 | return res.status(204).end() | ||
357 | } | ||
358 | |||
359 | // Example: if we reorder position 2 and insert after position 5 (so at position 6): # 1 2 3 4 5 6 7 8 9 | ||
360 | // * increase position when position > 5 # 1 2 3 4 5 7 8 9 10 | ||
361 | // * update position 2 -> position 6 # 1 3 4 5 6 7 8 9 10 | ||
362 | // * decrease position when position position > 2 # 1 2 3 4 5 6 7 8 9 | ||
363 | await sequelizeTypescript.transaction(async t => { | ||
364 | const newPosition = insertAfter + 1 | ||
365 | |||
366 | // Add space after the position when we want to insert our reordered elements (increase) | ||
367 | await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, newPosition, null, reorderLength, t) | ||
368 | |||
369 | let oldPosition = start | ||
370 | |||
371 | // We incremented the position of the elements we want to reorder | ||
372 | if (start >= newPosition) oldPosition += reorderLength | ||
373 | |||
374 | const endOldPosition = oldPosition + reorderLength - 1 | ||
375 | // Insert our reordered elements in their place (update) | ||
376 | await VideoPlaylistElementModel.reassignPositionOf(videoPlaylist.id, oldPosition, endOldPosition, newPosition, t) | ||
377 | |||
378 | // Decrease positions of elements after the old position of our ordered elements (decrease) | ||
379 | await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t) | ||
380 | |||
381 | await sendUpdateVideoPlaylist(videoPlaylist, t) | ||
382 | }) | ||
383 | |||
384 | logger.info( | ||
385 | 'Reordered playlist %s (inserted after %d elements %d - %d).', | ||
386 | videoPlaylist.uuid, insertAfter, start, start + reorderLength - 1 | ||
387 | ) | ||
388 | |||
389 | return res.type('json').status(204).end() | ||
390 | } | ||
391 | |||
392 | async function getVideoPlaylistVideos (req: express.Request, res: express.Response) { | ||
393 | const videoPlaylistInstance: VideoPlaylistModel = res.locals.videoPlaylist | ||
394 | const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined | ||
395 | |||
396 | const resultList = await VideoModel.listForApi({ | ||
397 | followerActorId, | ||
398 | start: req.query.start, | ||
399 | count: req.query.count, | ||
400 | sort: 'VideoPlaylistElements.position', | ||
401 | includeLocalVideos: true, | ||
402 | categoryOneOf: req.query.categoryOneOf, | ||
403 | licenceOneOf: req.query.licenceOneOf, | ||
404 | languageOneOf: req.query.languageOneOf, | ||
405 | tagsOneOf: req.query.tagsOneOf, | ||
406 | tagsAllOf: req.query.tagsAllOf, | ||
407 | filter: req.query.filter, | ||
408 | nsfw: buildNSFWFilter(res, req.query.nsfw), | ||
409 | withFiles: false, | ||
410 | videoPlaylistId: videoPlaylistInstance.id, | ||
411 | user: res.locals.oauth ? res.locals.oauth.token.User : undefined | ||
412 | }) | ||
413 | |||
414 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | ||
415 | } | ||
diff --git a/server/controllers/api/videos/blacklist.ts b/server/controllers/api/videos/blacklist.ts index 43b0516e7..b01296200 100644 --- a/server/controllers/api/videos/blacklist.ts +++ b/server/controllers/api/videos/blacklist.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { VideoBlacklist, UserRight, VideoBlacklistCreate } from '../../../../shared' | 2 | import { UserRight, VideoBlacklist, VideoBlacklistCreate } from '../../../../shared' |
3 | import { logger } from '../../../helpers/logger' | 3 | import { logger } from '../../../helpers/logger' |
4 | import { getFormattedObjects } from '../../../helpers/utils' | 4 | import { getFormattedObjects } from '../../../helpers/utils' |
5 | import { | 5 | import { |
@@ -18,7 +18,7 @@ import { VideoBlacklistModel } from '../../../models/video/video-blacklist' | |||
18 | import { sequelizeTypescript } from '../../../initializers' | 18 | import { sequelizeTypescript } from '../../../initializers' |
19 | import { Notifier } from '../../../lib/notifier' | 19 | import { Notifier } from '../../../lib/notifier' |
20 | import { VideoModel } from '../../../models/video/video' | 20 | import { VideoModel } from '../../../models/video/video' |
21 | import { sendCreateVideo, sendDeleteVideo, sendUpdateVideo } from '../../../lib/activitypub/send' | 21 | import { sendDeleteVideo } from '../../../lib/activitypub/send' |
22 | import { federateVideoIfNeeded } from '../../../lib/activitypub' | 22 | import { federateVideoIfNeeded } from '../../../lib/activitypub' |
23 | 23 | ||
24 | const blacklistRouter = express.Router() | 24 | const blacklistRouter = express.Router() |
diff --git a/server/controllers/services.ts b/server/controllers/services.ts index 352d0b19a..680c3c37f 100644 --- a/server/controllers/services.ts +++ b/server/controllers/services.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { CONFIG, EMBED_SIZE, PREVIEWS_SIZE } from '../initializers' | 2 | import { CONFIG, EMBED_SIZE, PREVIEWS_SIZE } from '../initializers' |
3 | import { asyncMiddleware, oembedValidator } from '../middlewares' | 3 | import { asyncMiddleware, oembedValidator } from '../middlewares' |
4 | import { accountsNameWithHostGetValidator } from '../middlewares/validators' | 4 | import { accountNameWithHostGetValidator } from '../middlewares/validators' |
5 | import { VideoModel } from '../models/video/video' | 5 | import { VideoModel } from '../models/video/video' |
6 | 6 | ||
7 | const servicesRouter = express.Router() | 7 | const servicesRouter = express.Router() |
@@ -11,7 +11,7 @@ servicesRouter.use('/oembed', | |||
11 | generateOEmbed | 11 | generateOEmbed |
12 | ) | 12 | ) |
13 | servicesRouter.use('/redirect/accounts/:accountName', | 13 | servicesRouter.use('/redirect/accounts/:accountName', |
14 | asyncMiddleware(accountsNameWithHostGetValidator), | 14 | asyncMiddleware(accountNameWithHostGetValidator), |
15 | redirectToAccountUrl | 15 | redirectToAccountUrl |
16 | ) | 16 | ) |
17 | 17 | ||
diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts index e850efe13..31c6187d1 100644 --- a/server/helpers/activitypub.ts +++ b/server/helpers/activitypub.ts | |||
@@ -28,6 +28,9 @@ function activityPubContextify <T> (data: T) { | |||
28 | state: 'sc:Number', | 28 | state: 'sc:Number', |
29 | size: 'sc:Number', | 29 | size: 'sc:Number', |
30 | fps: 'sc:Number', | 30 | fps: 'sc:Number', |
31 | startTimestamp: 'sc:Number', | ||
32 | stopTimestamp: 'sc:Number', | ||
33 | position: 'sc:Number', | ||
31 | commentsEnabled: 'sc:Boolean', | 34 | commentsEnabled: 'sc:Boolean', |
32 | downloadEnabled: 'sc:Boolean', | 35 | downloadEnabled: 'sc:Boolean', |
33 | waitTranscoding: 'sc:Boolean', | 36 | waitTranscoding: 'sc:Boolean', |
@@ -46,6 +49,10 @@ function activityPubContextify <T> (data: T) { | |||
46 | '@id': 'as:dislikes', | 49 | '@id': 'as:dislikes', |
47 | '@type': '@id' | 50 | '@type': '@id' |
48 | }, | 51 | }, |
52 | playlists: { | ||
53 | '@id': 'pt:playlists', | ||
54 | '@type': '@id' | ||
55 | }, | ||
49 | shares: { | 56 | shares: { |
50 | '@id': 'as:shares', | 57 | '@id': 'as:shares', |
51 | '@type': '@id' | 58 | '@type': '@id' |
@@ -67,7 +74,7 @@ async function activityPubCollectionPagination (baseUrl: string, handler: Activi | |||
67 | 74 | ||
68 | return { | 75 | return { |
69 | id: baseUrl, | 76 | id: baseUrl, |
70 | type: 'OrderedCollection', | 77 | type: 'OrderedCollectionPage', |
71 | totalItems: result.total, | 78 | totalItems: result.total, |
72 | first: baseUrl + '?page=1' | 79 | first: baseUrl + '?page=1' |
73 | } | 80 | } |
diff --git a/server/helpers/custom-validators/activitypub/activity.ts b/server/helpers/custom-validators/activitypub/activity.ts index b24590d9d..e0d170d9d 100644 --- a/server/helpers/custom-validators/activitypub/activity.ts +++ b/server/helpers/custom-validators/activitypub/activity.ts | |||
@@ -9,6 +9,7 @@ import { isViewActivityValid } from './view' | |||
9 | import { exists } from '../misc' | 9 | import { exists } from '../misc' |
10 | import { isCacheFileObjectValid } from './cache-file' | 10 | import { isCacheFileObjectValid } from './cache-file' |
11 | import { isFlagActivityValid } from './flag' | 11 | import { isFlagActivityValid } from './flag' |
12 | import { isPlaylistObjectValid } from './playlist' | ||
12 | 13 | ||
13 | function isRootActivityValid (activity: any) { | 14 | function isRootActivityValid (activity: any) { |
14 | return Array.isArray(activity['@context']) && ( | 15 | return Array.isArray(activity['@context']) && ( |
@@ -78,6 +79,7 @@ function checkCreateActivity (activity: any) { | |||
78 | isViewActivityValid(activity.object) || | 79 | isViewActivityValid(activity.object) || |
79 | isDislikeActivityValid(activity.object) || | 80 | isDislikeActivityValid(activity.object) || |
80 | isFlagActivityValid(activity.object) || | 81 | isFlagActivityValid(activity.object) || |
82 | isPlaylistObjectValid(activity.object) || | ||
81 | 83 | ||
82 | isCacheFileObjectValid(activity.object) || | 84 | isCacheFileObjectValid(activity.object) || |
83 | sanitizeAndCheckVideoCommentObject(activity.object) || | 85 | sanitizeAndCheckVideoCommentObject(activity.object) || |
@@ -89,6 +91,7 @@ function checkUpdateActivity (activity: any) { | |||
89 | return isBaseActivityValid(activity, 'Update') && | 91 | return isBaseActivityValid(activity, 'Update') && |
90 | ( | 92 | ( |
91 | isCacheFileObjectValid(activity.object) || | 93 | isCacheFileObjectValid(activity.object) || |
94 | isPlaylistObjectValid(activity.object) || | ||
92 | sanitizeAndCheckVideoTorrentObject(activity.object) || | 95 | sanitizeAndCheckVideoTorrentObject(activity.object) || |
93 | sanitizeAndCheckActorObject(activity.object) | 96 | sanitizeAndCheckActorObject(activity.object) |
94 | ) | 97 | ) |
diff --git a/server/helpers/custom-validators/activitypub/playlist.ts b/server/helpers/custom-validators/activitypub/playlist.ts new file mode 100644 index 000000000..ecdc7975e --- /dev/null +++ b/server/helpers/custom-validators/activitypub/playlist.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { exists } from '../misc' | ||
2 | import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' | ||
3 | import * as validator from 'validator' | ||
4 | import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object' | ||
5 | import { isActivityPubUrlValid } from './misc' | ||
6 | |||
7 | function isPlaylistObjectValid (object: PlaylistObject) { | ||
8 | return exists(object) && | ||
9 | object.type === 'Playlist' && | ||
10 | validator.isInt(object.totalItems + '') | ||
11 | } | ||
12 | |||
13 | function isPlaylistElementObjectValid (object: PlaylistElementObject) { | ||
14 | return exists(object) && | ||
15 | object.type === 'PlaylistElement' && | ||
16 | validator.isInt(object.position + '') && | ||
17 | isActivityPubUrlValid(object.url) | ||
18 | } | ||
19 | |||
20 | // --------------------------------------------------------------------------- | ||
21 | |||
22 | export { | ||
23 | isPlaylistObjectValid, | ||
24 | isPlaylistElementObjectValid | ||
25 | } | ||
diff --git a/server/helpers/custom-validators/video-playlists.ts b/server/helpers/custom-validators/video-playlists.ts new file mode 100644 index 000000000..0f5af4ec0 --- /dev/null +++ b/server/helpers/custom-validators/video-playlists.ts | |||
@@ -0,0 +1,44 @@ | |||
1 | import { exists } from './misc' | ||
2 | import * as validator from 'validator' | ||
3 | import { CONSTRAINTS_FIELDS, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers' | ||
4 | import * as express from 'express' | ||
5 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
6 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' | ||
7 | |||
8 | const PLAYLISTS_CONSTRAINT_FIELDS = CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS | ||
9 | |||
10 | function isVideoPlaylistNameValid (value: any) { | ||
11 | return exists(value) && validator.isLength(value, PLAYLISTS_CONSTRAINT_FIELDS.NAME) | ||
12 | } | ||
13 | |||
14 | function isVideoPlaylistDescriptionValid (value: any) { | ||
15 | return value === null || (exists(value) && validator.isLength(value, PLAYLISTS_CONSTRAINT_FIELDS.DESCRIPTION)) | ||
16 | } | ||
17 | |||
18 | function isVideoPlaylistPrivacyValid (value: number) { | ||
19 | return validator.isInt(value + '') && VIDEO_PLAYLIST_PRIVACIES[ value ] !== undefined | ||
20 | } | ||
21 | |||
22 | async function isVideoPlaylistExist (id: number | string, res: express.Response) { | ||
23 | const videoPlaylist = await VideoPlaylistModel.load(id, undefined) | ||
24 | |||
25 | if (!videoPlaylist) { | ||
26 | res.status(404) | ||
27 | .json({ error: 'Video playlist not found' }) | ||
28 | .end() | ||
29 | |||
30 | return false | ||
31 | } | ||
32 | |||
33 | res.locals.videoPlaylist = videoPlaylist | ||
34 | return true | ||
35 | } | ||
36 | |||
37 | // --------------------------------------------------------------------------- | ||
38 | |||
39 | export { | ||
40 | isVideoPlaylistExist, | ||
41 | isVideoPlaylistNameValid, | ||
42 | isVideoPlaylistDescriptionValid, | ||
43 | isVideoPlaylistPrivacyValid | ||
44 | } | ||
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index dd04aa5f6..d00d24c4c 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts | |||
@@ -165,7 +165,7 @@ function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: Use | |||
165 | return true | 165 | return true |
166 | } | 166 | } |
167 | 167 | ||
168 | async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') { | 168 | async function isVideoExist (id: number | string, res: Response, fetchType: VideoFetchType = 'all') { |
169 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined | 169 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined |
170 | 170 | ||
171 | const video = await fetchVideo(id, fetchType, userId) | 171 | const video = await fetchVideo(id, fetchType, userId) |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 0d9a6a512..154a9cffe 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -10,6 +10,7 @@ import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' | |||
10 | import { invert } from 'lodash' | 10 | import { invert } from 'lodash' |
11 | import { CronRepeatOptions, EveryRepeatOptions } from 'bull' | 11 | import { CronRepeatOptions, EveryRepeatOptions } from 'bull' |
12 | import * as bytes from 'bytes' | 12 | import * as bytes from 'bytes' |
13 | import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
13 | 14 | ||
14 | // Use a variable to reload the configuration if we need | 15 | // Use a variable to reload the configuration if we need |
15 | let config: IConfig = require('config') | 16 | let config: IConfig = require('config') |
@@ -52,7 +53,9 @@ const SORTABLE_COLUMNS = { | |||
52 | ACCOUNTS_BLOCKLIST: [ 'createdAt' ], | 53 | ACCOUNTS_BLOCKLIST: [ 'createdAt' ], |
53 | SERVERS_BLOCKLIST: [ 'createdAt' ], | 54 | SERVERS_BLOCKLIST: [ 'createdAt' ], |
54 | 55 | ||
55 | USER_NOTIFICATIONS: [ 'createdAt' ] | 56 | USER_NOTIFICATIONS: [ 'createdAt' ], |
57 | |||
58 | VIDEO_PLAYLISTS: [ 'createdAt' ] | ||
56 | } | 59 | } |
57 | 60 | ||
58 | const OAUTH_LIFETIME = { | 61 | const OAUTH_LIFETIME = { |
@@ -386,6 +389,17 @@ let CONSTRAINTS_FIELDS = { | |||
386 | FILE_SIZE: { min: 10 }, | 389 | FILE_SIZE: { min: 10 }, |
387 | URL: { min: 3, max: 2000 } // Length | 390 | URL: { min: 3, max: 2000 } // Length |
388 | }, | 391 | }, |
392 | VIDEO_PLAYLISTS: { | ||
393 | NAME: { min: 1, max: 120 }, // Length | ||
394 | DESCRIPTION: { min: 3, max: 1000 }, // Length | ||
395 | URL: { min: 3, max: 2000 }, // Length | ||
396 | IMAGE: { | ||
397 | EXTNAME: [ '.jpg', '.jpeg' ], | ||
398 | FILE_SIZE: { | ||
399 | max: 2 * 1024 * 1024 // 2MB | ||
400 | } | ||
401 | } | ||
402 | }, | ||
389 | ACTORS: { | 403 | ACTORS: { |
390 | PUBLIC_KEY: { min: 10, max: 5000 }, // Length | 404 | PUBLIC_KEY: { min: 10, max: 5000 }, // Length |
391 | PRIVATE_KEY: { min: 10, max: 5000 }, // Length | 405 | PRIVATE_KEY: { min: 10, max: 5000 }, // Length |
@@ -502,6 +516,12 @@ const VIDEO_ABUSE_STATES = { | |||
502 | [VideoAbuseState.ACCEPTED]: 'Accepted' | 516 | [VideoAbuseState.ACCEPTED]: 'Accepted' |
503 | } | 517 | } |
504 | 518 | ||
519 | const VIDEO_PLAYLIST_PRIVACIES = { | ||
520 | [VideoPlaylistPrivacy.PUBLIC]: 'Public', | ||
521 | [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', | ||
522 | [VideoPlaylistPrivacy.PRIVATE]: 'Private' | ||
523 | } | ||
524 | |||
505 | const MIMETYPES = { | 525 | const MIMETYPES = { |
506 | VIDEO: { | 526 | VIDEO: { |
507 | MIMETYPE_EXT: buildVideoMimetypeExt(), | 527 | MIMETYPE_EXT: buildVideoMimetypeExt(), |
@@ -786,6 +806,7 @@ export { | |||
786 | VIDEO_IMPORT_STATES, | 806 | VIDEO_IMPORT_STATES, |
787 | VIDEO_VIEW_LIFETIME, | 807 | VIDEO_VIEW_LIFETIME, |
788 | CONTACT_FORM_LIFETIME, | 808 | CONTACT_FORM_LIFETIME, |
809 | VIDEO_PLAYLIST_PRIVACIES, | ||
789 | buildLanguages | 810 | buildLanguages |
790 | } | 811 | } |
791 | 812 | ||
diff --git a/server/initializers/database.ts b/server/initializers/database.ts index fe296142d..541ebbecf 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts | |||
@@ -34,6 +34,8 @@ import { ServerBlocklistModel } from '../models/server/server-blocklist' | |||
34 | import { UserNotificationModel } from '../models/account/user-notification' | 34 | import { UserNotificationModel } from '../models/account/user-notification' |
35 | import { UserNotificationSettingModel } from '../models/account/user-notification-setting' | 35 | import { UserNotificationSettingModel } from '../models/account/user-notification-setting' |
36 | import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' | 36 | import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' |
37 | import { VideoPlaylistModel } from '../models/video/video-playlist' | ||
38 | import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' | ||
37 | 39 | ||
38 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string | 40 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string |
39 | 41 | ||
@@ -101,7 +103,9 @@ async function initDatabaseModels (silent: boolean) { | |||
101 | ServerBlocklistModel, | 103 | ServerBlocklistModel, |
102 | UserNotificationModel, | 104 | UserNotificationModel, |
103 | UserNotificationSettingModel, | 105 | UserNotificationSettingModel, |
104 | VideoStreamingPlaylistModel | 106 | VideoStreamingPlaylistModel, |
107 | VideoPlaylistModel, | ||
108 | VideoPlaylistElementModel | ||
105 | ]) | 109 | ]) |
106 | 110 | ||
107 | // Check extensions exist in the database | 111 | // Check extensions exist in the database |
diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts index a3f379b76..f77df8b78 100644 --- a/server/lib/activitypub/actor.ts +++ b/server/lib/activitypub/actor.ts | |||
@@ -44,6 +44,7 @@ async function getOrCreateActorAndServerAndModel ( | |||
44 | ) { | 44 | ) { |
45 | const actorUrl = getAPId(activityActor) | 45 | const actorUrl = getAPId(activityActor) |
46 | let created = false | 46 | let created = false |
47 | let accountPlaylistsUrl: string | ||
47 | 48 | ||
48 | let actor = await fetchActorByUrl(actorUrl, fetchType) | 49 | let actor = await fetchActorByUrl(actorUrl, fetchType) |
49 | // Orphan actor (not associated to an account of channel) so recreate it | 50 | // Orphan actor (not associated to an account of channel) so recreate it |
@@ -70,7 +71,8 @@ async function getOrCreateActorAndServerAndModel ( | |||
70 | 71 | ||
71 | try { | 72 | try { |
72 | // Don't recurse another time | 73 | // Don't recurse another time |
73 | ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', false) | 74 | const recurseIfNeeded = false |
75 | ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', recurseIfNeeded) | ||
74 | } catch (err) { | 76 | } catch (err) { |
75 | logger.error('Cannot get or create account attributed to video channel ' + actor.url) | 77 | logger.error('Cannot get or create account attributed to video channel ' + actor.url) |
76 | throw new Error(err) | 78 | throw new Error(err) |
@@ -79,6 +81,7 @@ async function getOrCreateActorAndServerAndModel ( | |||
79 | 81 | ||
80 | actor = await retryTransactionWrapper(saveActorAndServerAndModelIfNotExist, result, ownerActor) | 82 | actor = await retryTransactionWrapper(saveActorAndServerAndModelIfNotExist, result, ownerActor) |
81 | created = true | 83 | created = true |
84 | accountPlaylistsUrl = result.playlists | ||
82 | } | 85 | } |
83 | 86 | ||
84 | if (actor.Account) actor.Account.Actor = actor | 87 | if (actor.Account) actor.Account.Actor = actor |
@@ -92,6 +95,12 @@ async function getOrCreateActorAndServerAndModel ( | |||
92 | await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }) | 95 | await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }) |
93 | } | 96 | } |
94 | 97 | ||
98 | // We created a new account: fetch the playlists | ||
99 | if (created === true && actor.Account && accountPlaylistsUrl) { | ||
100 | const payload = { uri: accountPlaylistsUrl, accountId: actor.Account.id, type: 'account-playlists' as 'account-playlists' } | ||
101 | await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }) | ||
102 | } | ||
103 | |||
95 | return actorRefreshed | 104 | return actorRefreshed |
96 | } | 105 | } |
97 | 106 | ||
@@ -342,6 +351,7 @@ type FetchRemoteActorResult = { | |||
342 | name: string | 351 | name: string |
343 | summary: string | 352 | summary: string |
344 | support?: string | 353 | support?: string |
354 | playlists?: string | ||
345 | avatarName?: string | 355 | avatarName?: string |
346 | attributedTo: ActivityPubAttributedTo[] | 356 | attributedTo: ActivityPubAttributedTo[] |
347 | } | 357 | } |
@@ -398,6 +408,7 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe | |||
398 | avatarName, | 408 | avatarName, |
399 | summary: actorJSON.summary, | 409 | summary: actorJSON.summary, |
400 | support: actorJSON.support, | 410 | support: actorJSON.support, |
411 | playlists: actorJSON.playlists, | ||
401 | attributedTo: actorJSON.attributedTo | 412 | attributedTo: actorJSON.attributedTo |
402 | } | 413 | } |
403 | } | 414 | } |
diff --git a/server/lib/activitypub/cache-file.ts b/server/lib/activitypub/cache-file.ts index 9a40414bb..597003135 100644 --- a/server/lib/activitypub/cache-file.ts +++ b/server/lib/activitypub/cache-file.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { ActivityPlaylistUrlObject, ActivityVideoUrlObject, CacheFileObject } from '../../../shared/index' | 1 | import { CacheFileObject } from '../../../shared/index' |
2 | import { VideoModel } from '../../models/video/video' | 2 | import { VideoModel } from '../../models/video/video' |
3 | import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' | 3 | import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' |
4 | import { Transaction } from 'sequelize' | 4 | import { Transaction } from 'sequelize' |
diff --git a/server/lib/activitypub/crawl.ts b/server/lib/activitypub/crawl.ts index 1b9b14c2e..2675524c6 100644 --- a/server/lib/activitypub/crawl.ts +++ b/server/lib/activitypub/crawl.ts | |||
@@ -4,7 +4,7 @@ import { logger } from '../../helpers/logger' | |||
4 | import * as Bluebird from 'bluebird' | 4 | import * as Bluebird from 'bluebird' |
5 | import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub' | 5 | import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub' |
6 | 6 | ||
7 | async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) { | 7 | async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => (Promise<any> | Bluebird<any>)) { |
8 | logger.info('Crawling ActivityPub data on %s.', uri) | 8 | logger.info('Crawling ActivityPub data on %s.', uri) |
9 | 9 | ||
10 | const options = { | 10 | const options = { |
diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts new file mode 100644 index 000000000..c9b428c92 --- /dev/null +++ b/server/lib/activitypub/playlist.ts | |||
@@ -0,0 +1,162 @@ | |||
1 | import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' | ||
2 | import { crawlCollectionPage } from './crawl' | ||
3 | import { ACTIVITY_PUB, CONFIG, CRAWL_REQUEST_CONCURRENCY, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers' | ||
4 | import { AccountModel } from '../../models/account/account' | ||
5 | import { isArray } from '../../helpers/custom-validators/misc' | ||
6 | import { getOrCreateActorAndServerAndModel } from './actor' | ||
7 | import { logger } from '../../helpers/logger' | ||
8 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
9 | import { doRequest, downloadImage } from '../../helpers/requests' | ||
10 | import { checkUrlsSameHost } from '../../helpers/activitypub' | ||
11 | import * as Bluebird from 'bluebird' | ||
12 | import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' | ||
13 | import { getOrCreateVideoAndAccountAndChannel } from './videos' | ||
14 | import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '../../helpers/custom-validators/activitypub/playlist' | ||
15 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' | ||
16 | import { VideoModel } from '../../models/video/video' | ||
17 | import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' | ||
18 | import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
19 | import { ActivityIconObject } from '../../../shared/models/activitypub/objects' | ||
20 | |||
21 | function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) { | ||
22 | const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED | ||
23 | |||
24 | return { | ||
25 | name: playlistObject.name, | ||
26 | description: playlistObject.content, | ||
27 | privacy, | ||
28 | url: playlistObject.id, | ||
29 | uuid: playlistObject.uuid, | ||
30 | ownerAccountId: byAccount.id, | ||
31 | videoChannelId: null | ||
32 | } | ||
33 | } | ||
34 | |||
35 | function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: VideoPlaylistModel, video: VideoModel) { | ||
36 | return { | ||
37 | position: elementObject.position, | ||
38 | url: elementObject.id, | ||
39 | startTimestamp: elementObject.startTimestamp || null, | ||
40 | stopTimestamp: elementObject.stopTimestamp || null, | ||
41 | videoPlaylistId: videoPlaylist.id, | ||
42 | videoId: video.id | ||
43 | } | ||
44 | } | ||
45 | |||
46 | async function createAccountPlaylists (playlistUrls: string[], account: AccountModel) { | ||
47 | await Bluebird.map(playlistUrls, async playlistUrl => { | ||
48 | try { | ||
49 | const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl) | ||
50 | if (exists === true) return | ||
51 | |||
52 | // Fetch url | ||
53 | const { body } = await doRequest<PlaylistObject>({ | ||
54 | uri: playlistUrl, | ||
55 | json: true, | ||
56 | activityPub: true | ||
57 | }) | ||
58 | |||
59 | if (!isPlaylistObjectValid(body)) { | ||
60 | throw new Error(`Invalid playlist object when fetch account playlists: ${JSON.stringify(body)}`) | ||
61 | } | ||
62 | |||
63 | if (!isArray(body.to)) { | ||
64 | throw new Error('Playlist does not have an audience.') | ||
65 | } | ||
66 | |||
67 | return createOrUpdateVideoPlaylist(body, account, body.to) | ||
68 | } catch (err) { | ||
69 | logger.warn('Cannot add playlist element %s.', playlistUrl, { err }) | ||
70 | } | ||
71 | }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) | ||
72 | } | ||
73 | |||
74 | async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) { | ||
75 | const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to) | ||
76 | |||
77 | if (isArray(playlistObject.attributedTo) && playlistObject.attributedTo.length === 1) { | ||
78 | const actor = await getOrCreateActorAndServerAndModel(playlistObject.attributedTo[0]) | ||
79 | |||
80 | if (actor.VideoChannel) { | ||
81 | playlistAttributes.videoChannelId = actor.VideoChannel.id | ||
82 | } else { | ||
83 | logger.warn('Attributed to of video playlist %s is not a video channel.', playlistObject.id, { playlistObject }) | ||
84 | } | ||
85 | } | ||
86 | |||
87 | const [ playlist ] = await VideoPlaylistModel.upsert<VideoPlaylistModel>(playlistAttributes, { returning: true }) | ||
88 | |||
89 | let accItems: string[] = [] | ||
90 | await crawlCollectionPage<string>(playlistObject.id, items => { | ||
91 | accItems = accItems.concat(items) | ||
92 | |||
93 | return Promise.resolve() | ||
94 | }) | ||
95 | |||
96 | // Empty playlists generally do not have a miniature, so skip it | ||
97 | if (accItems.length !== 0) { | ||
98 | try { | ||
99 | await generateThumbnailFromUrl(playlist, playlistObject.icon) | ||
100 | } catch (err) { | ||
101 | logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err }) | ||
102 | } | ||
103 | } | ||
104 | |||
105 | return resetVideoPlaylistElements(accItems, playlist) | ||
106 | } | ||
107 | |||
108 | // --------------------------------------------------------------------------- | ||
109 | |||
110 | export { | ||
111 | createAccountPlaylists, | ||
112 | playlistObjectToDBAttributes, | ||
113 | playlistElementObjectToDBAttributes, | ||
114 | createOrUpdateVideoPlaylist | ||
115 | } | ||
116 | |||
117 | // --------------------------------------------------------------------------- | ||
118 | |||
119 | async function resetVideoPlaylistElements (elementUrls: string[], playlist: VideoPlaylistModel) { | ||
120 | const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = [] | ||
121 | |||
122 | await Bluebird.map(elementUrls, async elementUrl => { | ||
123 | try { | ||
124 | // Fetch url | ||
125 | const { body } = await doRequest<PlaylistElementObject>({ | ||
126 | uri: elementUrl, | ||
127 | json: true, | ||
128 | activityPub: true | ||
129 | }) | ||
130 | |||
131 | if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in video get playlist element ${elementUrl}`) | ||
132 | |||
133 | if (checkUrlsSameHost(body.id, elementUrl) !== true) { | ||
134 | throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`) | ||
135 | } | ||
136 | |||
137 | const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: { id: body.url }, fetchType: 'only-video' }) | ||
138 | |||
139 | elementsToCreate.push(playlistElementObjectToDBAttributes(body, playlist, video)) | ||
140 | } catch (err) { | ||
141 | logger.warn('Cannot add playlist element %s.', elementUrl, { err }) | ||
142 | } | ||
143 | }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) | ||
144 | |||
145 | await sequelizeTypescript.transaction(async t => { | ||
146 | await VideoPlaylistElementModel.deleteAllOf(playlist.id, t) | ||
147 | |||
148 | for (const element of elementsToCreate) { | ||
149 | await VideoPlaylistElementModel.create(element, { transaction: t }) | ||
150 | } | ||
151 | }) | ||
152 | |||
153 | logger.info('Reset playlist %s with %s elements.', playlist.url, elementsToCreate.length) | ||
154 | |||
155 | return undefined | ||
156 | } | ||
157 | |||
158 | function generateThumbnailFromUrl (playlist: VideoPlaylistModel, icon: ActivityIconObject) { | ||
159 | const thumbnailName = playlist.getThumbnailName() | ||
160 | |||
161 | return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE) | ||
162 | } | ||
diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts index 5f4d793a5..e882669ce 100644 --- a/server/lib/activitypub/process/process-create.ts +++ b/server/lib/activitypub/process/process-create.ts | |||
@@ -12,6 +12,8 @@ import { Notifier } from '../../notifier' | |||
12 | import { processViewActivity } from './process-view' | 12 | import { processViewActivity } from './process-view' |
13 | import { processDislikeActivity } from './process-dislike' | 13 | import { processDislikeActivity } from './process-dislike' |
14 | import { processFlagActivity } from './process-flag' | 14 | import { processFlagActivity } from './process-flag' |
15 | import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' | ||
16 | import { createOrUpdateVideoPlaylist } from '../playlist' | ||
15 | 17 | ||
16 | async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) { | 18 | async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) { |
17 | const activityObject = activity.object | 19 | const activityObject = activity.object |
@@ -38,7 +40,11 @@ async function processCreateActivity (activity: ActivityCreate, byActor: ActorMo | |||
38 | } | 40 | } |
39 | 41 | ||
40 | if (activityType === 'CacheFile') { | 42 | if (activityType === 'CacheFile') { |
41 | return retryTransactionWrapper(processCacheFile, activity, byActor) | 43 | return retryTransactionWrapper(processCreateCacheFile, activity, byActor) |
44 | } | ||
45 | |||
46 | if (activityType === 'Playlist') { | ||
47 | return retryTransactionWrapper(processCreatePlaylist, activity, byActor) | ||
42 | } | 48 | } |
43 | 49 | ||
44 | logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) | 50 | logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) |
@@ -63,7 +69,7 @@ async function processCreateVideo (activity: ActivityCreate) { | |||
63 | return video | 69 | return video |
64 | } | 70 | } |
65 | 71 | ||
66 | async function processCacheFile (activity: ActivityCreate, byActor: ActorModel) { | 72 | async function processCreateCacheFile (activity: ActivityCreate, byActor: ActorModel) { |
67 | const cacheFile = activity.object as CacheFileObject | 73 | const cacheFile = activity.object as CacheFileObject |
68 | 74 | ||
69 | const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object }) | 75 | const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object }) |
@@ -98,3 +104,12 @@ async function processCreateVideoComment (activity: ActivityCreate, byActor: Act | |||
98 | 104 | ||
99 | if (created === true) Notifier.Instance.notifyOnNewComment(comment) | 105 | if (created === true) Notifier.Instance.notifyOnNewComment(comment) |
100 | } | 106 | } |
107 | |||
108 | async function processCreatePlaylist (activity: ActivityCreate, byActor: ActorModel) { | ||
109 | const playlistObject = activity.object as PlaylistObject | ||
110 | const byAccount = byActor.Account | ||
111 | |||
112 | if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url) | ||
113 | |||
114 | await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to) | ||
115 | } | ||
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts index c6b42d846..0b96ba352 100644 --- a/server/lib/activitypub/process/process-update.ts +++ b/server/lib/activitypub/process/process-update.ts | |||
@@ -12,6 +12,8 @@ import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-vali | |||
12 | import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file' | 12 | import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file' |
13 | import { createOrUpdateCacheFile } from '../cache-file' | 13 | import { createOrUpdateCacheFile } from '../cache-file' |
14 | import { forwardVideoRelatedActivity } from '../send/utils' | 14 | import { forwardVideoRelatedActivity } from '../send/utils' |
15 | import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' | ||
16 | import { createOrUpdateVideoPlaylist } from '../playlist' | ||
15 | 17 | ||
16 | async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorModel) { | 18 | async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorModel) { |
17 | const objectType = activity.object.type | 19 | const objectType = activity.object.type |
@@ -32,6 +34,10 @@ async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorMo | |||
32 | return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity) | 34 | return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity) |
33 | } | 35 | } |
34 | 36 | ||
37 | if (objectType === 'Playlist') { | ||
38 | return retryTransactionWrapper(processUpdatePlaylist, byActor, activity) | ||
39 | } | ||
40 | |||
35 | return undefined | 41 | return undefined |
36 | } | 42 | } |
37 | 43 | ||
@@ -135,3 +141,12 @@ async function processUpdateActor (actor: ActorModel, activity: ActivityUpdate) | |||
135 | throw err | 141 | throw err |
136 | } | 142 | } |
137 | } | 143 | } |
144 | |||
145 | async function processUpdatePlaylist (byActor: ActorModel, activity: ActivityUpdate) { | ||
146 | const playlistObject = activity.object as PlaylistObject | ||
147 | const byAccount = byActor.Account | ||
148 | |||
149 | if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url) | ||
150 | |||
151 | await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to) | ||
152 | } | ||
diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts index ef20e404c..bacdb97e3 100644 --- a/server/lib/activitypub/send/send-create.ts +++ b/server/lib/activitypub/send/send-create.ts | |||
@@ -8,6 +8,9 @@ import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unic | |||
8 | import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience' | 8 | import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience' |
9 | import { logger } from '../../../helpers/logger' | 9 | import { logger } from '../../../helpers/logger' |
10 | import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' | 10 | import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' |
11 | import { VideoPlaylistModel } from '../../../models/video/video-playlist' | ||
12 | import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
13 | import { getServerActor } from '../../../helpers/utils' | ||
11 | 14 | ||
12 | async function sendCreateVideo (video: VideoModel, t: Transaction) { | 15 | async function sendCreateVideo (video: VideoModel, t: Transaction) { |
13 | if (video.privacy === VideoPrivacy.PRIVATE) return undefined | 16 | if (video.privacy === VideoPrivacy.PRIVATE) return undefined |
@@ -34,6 +37,25 @@ async function sendCreateCacheFile (byActor: ActorModel, video: VideoModel, file | |||
34 | }) | 37 | }) |
35 | } | 38 | } |
36 | 39 | ||
40 | async function sendCreateVideoPlaylist (playlist: VideoPlaylistModel, t: Transaction) { | ||
41 | if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined | ||
42 | |||
43 | logger.info('Creating job to send create video playlist of %s.', playlist.url) | ||
44 | |||
45 | const byActor = playlist.OwnerAccount.Actor | ||
46 | const audience = getAudience(byActor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC) | ||
47 | |||
48 | const object = await playlist.toActivityPubObject() | ||
49 | const createActivity = buildCreateActivity(playlist.url, byActor, object, audience) | ||
50 | |||
51 | const serverActor = await getServerActor() | ||
52 | const toFollowersOf = [ byActor, serverActor ] | ||
53 | |||
54 | if (playlist.VideoChannel) toFollowersOf.push(playlist.VideoChannel.Actor) | ||
55 | |||
56 | return broadcastToFollowers(createActivity, byActor, toFollowersOf, t) | ||
57 | } | ||
58 | |||
37 | async function sendCreateVideoComment (comment: VideoCommentModel, t: Transaction) { | 59 | async function sendCreateVideoComment (comment: VideoCommentModel, t: Transaction) { |
38 | logger.info('Creating job to send comment %s.', comment.url) | 60 | logger.info('Creating job to send comment %s.', comment.url) |
39 | 61 | ||
@@ -92,6 +114,7 @@ export { | |||
92 | sendCreateVideo, | 114 | sendCreateVideo, |
93 | buildCreateActivity, | 115 | buildCreateActivity, |
94 | sendCreateVideoComment, | 116 | sendCreateVideoComment, |
117 | sendCreateVideoPlaylist, | ||
95 | sendCreateCacheFile | 118 | sendCreateCacheFile |
96 | } | 119 | } |
97 | 120 | ||
diff --git a/server/lib/activitypub/send/send-delete.ts b/server/lib/activitypub/send/send-delete.ts index 18969433a..016811e60 100644 --- a/server/lib/activitypub/send/send-delete.ts +++ b/server/lib/activitypub/send/send-delete.ts | |||
@@ -8,6 +8,8 @@ import { getDeleteActivityPubUrl } from '../url' | |||
8 | import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' | 8 | import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' |
9 | import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience' | 9 | import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience' |
10 | import { logger } from '../../../helpers/logger' | 10 | import { logger } from '../../../helpers/logger' |
11 | import { VideoPlaylistModel } from '../../../models/video/video-playlist' | ||
12 | import { getServerActor } from '../../../helpers/utils' | ||
11 | 13 | ||
12 | async function sendDeleteVideo (video: VideoModel, transaction: Transaction) { | 14 | async function sendDeleteVideo (video: VideoModel, transaction: Transaction) { |
13 | logger.info('Creating job to broadcast delete of video %s.', video.url) | 15 | logger.info('Creating job to broadcast delete of video %s.', video.url) |
@@ -64,12 +66,29 @@ async function sendDeleteVideoComment (videoComment: VideoCommentModel, t: Trans | |||
64 | return unicastTo(activity, byActor, videoComment.Video.VideoChannel.Account.Actor.sharedInboxUrl) | 66 | return unicastTo(activity, byActor, videoComment.Video.VideoChannel.Account.Actor.sharedInboxUrl) |
65 | } | 67 | } |
66 | 68 | ||
69 | async function sendDeleteVideoPlaylist (videoPlaylist: VideoPlaylistModel, t: Transaction) { | ||
70 | logger.info('Creating job to send delete of playlist %s.', videoPlaylist.url) | ||
71 | |||
72 | const byActor = videoPlaylist.OwnerAccount.Actor | ||
73 | |||
74 | const url = getDeleteActivityPubUrl(videoPlaylist.url) | ||
75 | const activity = buildDeleteActivity(url, videoPlaylist.url, byActor) | ||
76 | |||
77 | const serverActor = await getServerActor() | ||
78 | const toFollowersOf = [ byActor, serverActor ] | ||
79 | |||
80 | if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor) | ||
81 | |||
82 | return broadcastToFollowers(activity, byActor, toFollowersOf, t) | ||
83 | } | ||
84 | |||
67 | // --------------------------------------------------------------------------- | 85 | // --------------------------------------------------------------------------- |
68 | 86 | ||
69 | export { | 87 | export { |
70 | sendDeleteVideo, | 88 | sendDeleteVideo, |
71 | sendDeleteActor, | 89 | sendDeleteActor, |
72 | sendDeleteVideoComment | 90 | sendDeleteVideoComment, |
91 | sendDeleteVideoPlaylist | ||
73 | } | 92 | } |
74 | 93 | ||
75 | // --------------------------------------------------------------------------- | 94 | // --------------------------------------------------------------------------- |
diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts index 839f66470..3eb2704fd 100644 --- a/server/lib/activitypub/send/send-update.ts +++ b/server/lib/activitypub/send/send-update.ts | |||
@@ -12,8 +12,13 @@ import { audiencify, getActorsInvolvedInVideo, getAudience } from '../audience' | |||
12 | import { logger } from '../../../helpers/logger' | 12 | import { logger } from '../../../helpers/logger' |
13 | import { VideoCaptionModel } from '../../../models/video/video-caption' | 13 | import { VideoCaptionModel } from '../../../models/video/video-caption' |
14 | import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' | 14 | import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' |
15 | import { VideoPlaylistModel } from '../../../models/video/video-playlist' | ||
16 | import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
17 | import { getServerActor } from '../../../helpers/utils' | ||
15 | 18 | ||
16 | async function sendUpdateVideo (video: VideoModel, t: Transaction, overrodeByActor?: ActorModel) { | 19 | async function sendUpdateVideo (video: VideoModel, t: Transaction, overrodeByActor?: ActorModel) { |
20 | if (video.privacy === VideoPrivacy.PRIVATE) return undefined | ||
21 | |||
17 | logger.info('Creating job to update video %s.', video.url) | 22 | logger.info('Creating job to update video %s.', video.url) |
18 | 23 | ||
19 | const byActor = overrodeByActor ? overrodeByActor : video.VideoChannel.Account.Actor | 24 | const byActor = overrodeByActor ? overrodeByActor : video.VideoChannel.Account.Actor |
@@ -73,12 +78,35 @@ async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoR | |||
73 | return sendVideoRelatedActivity(activityBuilder, { byActor, video }) | 78 | return sendVideoRelatedActivity(activityBuilder, { byActor, video }) |
74 | } | 79 | } |
75 | 80 | ||
81 | async function sendUpdateVideoPlaylist (videoPlaylist: VideoPlaylistModel, t: Transaction) { | ||
82 | if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined | ||
83 | |||
84 | const byActor = videoPlaylist.OwnerAccount.Actor | ||
85 | |||
86 | logger.info('Creating job to update video playlist %s.', videoPlaylist.url) | ||
87 | |||
88 | const url = getUpdateActivityPubUrl(videoPlaylist.url, videoPlaylist.updatedAt.toISOString()) | ||
89 | |||
90 | const object = await videoPlaylist.toActivityPubObject() | ||
91 | const audience = getAudience(byActor, videoPlaylist.privacy === VideoPlaylistPrivacy.PUBLIC) | ||
92 | |||
93 | const updateActivity = buildUpdateActivity(url, byActor, object, audience) | ||
94 | |||
95 | const serverActor = await getServerActor() | ||
96 | const toFollowersOf = [ byActor, serverActor ] | ||
97 | |||
98 | if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor) | ||
99 | |||
100 | return broadcastToFollowers(updateActivity, byActor, toFollowersOf, t) | ||
101 | } | ||
102 | |||
76 | // --------------------------------------------------------------------------- | 103 | // --------------------------------------------------------------------------- |
77 | 104 | ||
78 | export { | 105 | export { |
79 | sendUpdateActor, | 106 | sendUpdateActor, |
80 | sendUpdateVideo, | 107 | sendUpdateVideo, |
81 | sendUpdateCacheFile | 108 | sendUpdateCacheFile, |
109 | sendUpdateVideoPlaylist | ||
82 | } | 110 | } |
83 | 111 | ||
84 | // --------------------------------------------------------------------------- | 112 | // --------------------------------------------------------------------------- |
diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts index 4229fe094..00bbbba2d 100644 --- a/server/lib/activitypub/url.ts +++ b/server/lib/activitypub/url.ts | |||
@@ -7,11 +7,21 @@ import { VideoCommentModel } from '../../models/video/video-comment' | |||
7 | import { VideoFileModel } from '../../models/video/video-file' | 7 | import { VideoFileModel } from '../../models/video/video-file' |
8 | import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' | 8 | import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' |
9 | import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist' | 9 | import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist' |
10 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
11 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' | ||
10 | 12 | ||
11 | function getVideoActivityPubUrl (video: VideoModel) { | 13 | function getVideoActivityPubUrl (video: VideoModel) { |
12 | return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid | 14 | return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid |
13 | } | 15 | } |
14 | 16 | ||
17 | function getVideoPlaylistActivityPubUrl (videoPlaylist: VideoPlaylistModel) { | ||
18 | return CONFIG.WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid | ||
19 | } | ||
20 | |||
21 | function getVideoPlaylistElementActivityPubUrl (videoPlaylist: VideoPlaylistModel, video: VideoModel) { | ||
22 | return CONFIG.WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid + '/' + video.uuid | ||
23 | } | ||
24 | |||
15 | function getVideoCacheFileActivityPubUrl (videoFile: VideoFileModel) { | 25 | function getVideoCacheFileActivityPubUrl (videoFile: VideoFileModel) { |
16 | const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : '' | 26 | const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : '' |
17 | 27 | ||
@@ -98,6 +108,8 @@ function getUndoActivityPubUrl (originalUrl: string) { | |||
98 | 108 | ||
99 | export { | 109 | export { |
100 | getVideoActivityPubUrl, | 110 | getVideoActivityPubUrl, |
111 | getVideoPlaylistElementActivityPubUrl, | ||
112 | getVideoPlaylistActivityPubUrl, | ||
101 | getVideoCacheStreamingPlaylistActivityPubUrl, | 113 | getVideoCacheStreamingPlaylistActivityPubUrl, |
102 | getVideoChannelActivityPubUrl, | 114 | getVideoChannelActivityPubUrl, |
103 | getAccountActivityPubUrl, | 115 | getAccountActivityPubUrl, |
diff --git a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts index 67ccfa995..52225f64f 100644 --- a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts +++ b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts | |||
@@ -5,13 +5,16 @@ import { addVideoComments } from '../../activitypub/video-comments' | |||
5 | import { crawlCollectionPage } from '../../activitypub/crawl' | 5 | import { crawlCollectionPage } from '../../activitypub/crawl' |
6 | import { VideoModel } from '../../../models/video/video' | 6 | import { VideoModel } from '../../../models/video/video' |
7 | import { addVideoShares, createRates } from '../../activitypub' | 7 | import { addVideoShares, createRates } from '../../activitypub' |
8 | import { createAccountPlaylists } from '../../activitypub/playlist' | ||
9 | import { AccountModel } from '../../../models/account/account' | ||
8 | 10 | ||
9 | type FetchType = 'activity' | 'video-likes' | 'video-dislikes' | 'video-shares' | 'video-comments' | 11 | type FetchType = 'activity' | 'video-likes' | 'video-dislikes' | 'video-shares' | 'video-comments' | 'account-playlists' |
10 | 12 | ||
11 | export type ActivitypubHttpFetcherPayload = { | 13 | export type ActivitypubHttpFetcherPayload = { |
12 | uri: string | 14 | uri: string |
13 | type: FetchType | 15 | type: FetchType |
14 | videoId?: number | 16 | videoId?: number |
17 | accountId?: number | ||
15 | } | 18 | } |
16 | 19 | ||
17 | async function processActivityPubHttpFetcher (job: Bull.Job) { | 20 | async function processActivityPubHttpFetcher (job: Bull.Job) { |
@@ -22,12 +25,16 @@ async function processActivityPubHttpFetcher (job: Bull.Job) { | |||
22 | let video: VideoModel | 25 | let video: VideoModel |
23 | if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId) | 26 | if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId) |
24 | 27 | ||
28 | let account: AccountModel | ||
29 | if (payload.accountId) account = await AccountModel.load(payload.accountId) | ||
30 | |||
25 | const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = { | 31 | const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = { |
26 | 'activity': items => processActivities(items, { outboxUrl: payload.uri }), | 32 | 'activity': items => processActivities(items, { outboxUrl: payload.uri }), |
27 | 'video-likes': items => createRates(items, video, 'like'), | 33 | 'video-likes': items => createRates(items, video, 'like'), |
28 | 'video-dislikes': items => createRates(items, video, 'dislike'), | 34 | 'video-dislikes': items => createRates(items, video, 'dislike'), |
29 | 'video-shares': items => addVideoShares(items, video), | 35 | 'video-shares': items => addVideoShares(items, video), |
30 | 'video-comments': items => addVideoComments(items, video) | 36 | 'video-comments': items => addVideoComments(items, video), |
37 | 'account-playlists': items => createAccountPlaylists(items, account) | ||
31 | } | 38 | } |
32 | 39 | ||
33 | return crawlCollectionPage(payload.uri, fetcherType[payload.type]) | 40 | return crawlCollectionPage(payload.uri, fetcherType[payload.type]) |
diff --git a/server/middlewares/validators/account.ts b/server/middlewares/validators/account.ts index b3a51e631..88c57eaa1 100644 --- a/server/middlewares/validators/account.ts +++ b/server/middlewares/validators/account.ts | |||
@@ -17,7 +17,7 @@ const localAccountValidator = [ | |||
17 | } | 17 | } |
18 | ] | 18 | ] |
19 | 19 | ||
20 | const accountsNameWithHostGetValidator = [ | 20 | const accountNameWithHostGetValidator = [ |
21 | param('accountName').exists().withMessage('Should have an account name with host'), | 21 | param('accountName').exists().withMessage('Should have an account name with host'), |
22 | 22 | ||
23 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 23 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
@@ -34,5 +34,5 @@ const accountsNameWithHostGetValidator = [ | |||
34 | 34 | ||
35 | export { | 35 | export { |
36 | localAccountValidator, | 36 | localAccountValidator, |
37 | accountsNameWithHostGetValidator | 37 | accountNameWithHostGetValidator |
38 | } | 38 | } |
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index 5ceda845f..ea59fbf73 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts | |||
@@ -19,6 +19,7 @@ const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUM | |||
19 | const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST) | 19 | const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST) |
20 | const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST) | 20 | const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST) |
21 | const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS) | 21 | const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS) |
22 | const SORTABLE_VIDEO_PLAYLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS) | ||
22 | 23 | ||
23 | const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) | 24 | const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) |
24 | const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) | 25 | const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) |
@@ -37,6 +38,7 @@ const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COL | |||
37 | const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS) | 38 | const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS) |
38 | const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS) | 39 | const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS) |
39 | const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS) | 40 | const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS) |
41 | const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS) | ||
40 | 42 | ||
41 | // --------------------------------------------------------------------------- | 43 | // --------------------------------------------------------------------------- |
42 | 44 | ||
@@ -57,5 +59,6 @@ export { | |||
57 | videoChannelsSearchSortValidator, | 59 | videoChannelsSearchSortValidator, |
58 | accountsBlocklistSortValidator, | 60 | accountsBlocklistSortValidator, |
59 | serversBlocklistSortValidator, | 61 | serversBlocklistSortValidator, |
60 | userNotificationsSortValidator | 62 | userNotificationsSortValidator, |
63 | videoPlaylistsSortValidator | ||
61 | } | 64 | } |
diff --git a/server/middlewares/validators/videos/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts index f039794e0..c2763ce51 100644 --- a/server/middlewares/validators/videos/video-channels.ts +++ b/server/middlewares/validators/videos/video-channels.ts | |||
@@ -16,19 +16,6 @@ import { areValidationErrors } from '../utils' | |||
16 | import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor' | 16 | import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor' |
17 | import { ActorModel } from '../../../models/activitypub/actor' | 17 | import { ActorModel } from '../../../models/activitypub/actor' |
18 | 18 | ||
19 | const listVideoAccountChannelsValidator = [ | ||
20 | param('accountName').exists().withMessage('Should have a valid account name'), | ||
21 | |||
22 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
23 | logger.debug('Checking listVideoAccountChannelsValidator parameters', { parameters: req.body }) | ||
24 | |||
25 | if (areValidationErrors(req, res)) return | ||
26 | if (!await isAccountNameWithHostExist(req.params.accountName, res)) return | ||
27 | |||
28 | return next() | ||
29 | } | ||
30 | ] | ||
31 | |||
32 | const videoChannelsAddValidator = [ | 19 | const videoChannelsAddValidator = [ |
33 | body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'), | 20 | body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'), |
34 | body('displayName').custom(isVideoChannelNameValid).withMessage('Should have a valid display name'), | 21 | body('displayName').custom(isVideoChannelNameValid).withMessage('Should have a valid display name'), |
@@ -127,7 +114,6 @@ const localVideoChannelValidator = [ | |||
127 | // --------------------------------------------------------------------------- | 114 | // --------------------------------------------------------------------------- |
128 | 115 | ||
129 | export { | 116 | export { |
130 | listVideoAccountChannelsValidator, | ||
131 | videoChannelsAddValidator, | 117 | videoChannelsAddValidator, |
132 | videoChannelsUpdateValidator, | 118 | videoChannelsUpdateValidator, |
133 | videoChannelsRemoveValidator, | 119 | videoChannelsRemoveValidator, |
diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts index 48d20f904..121df36b6 100644 --- a/server/middlewares/validators/videos/video-imports.ts +++ b/server/middlewares/validators/videos/video-imports.ts | |||
@@ -3,14 +3,14 @@ import { body } from 'express-validator/check' | |||
3 | import { isIdValid } from '../../../helpers/custom-validators/misc' | 3 | import { isIdValid } from '../../../helpers/custom-validators/misc' |
4 | import { logger } from '../../../helpers/logger' | 4 | import { logger } from '../../../helpers/logger' |
5 | import { areValidationErrors } from '../utils' | 5 | import { areValidationErrors } from '../utils' |
6 | import { getCommonVideoAttributes } from './videos' | 6 | import { getCommonVideoEditAttributes } from './videos' |
7 | import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' | 7 | import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' |
8 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | 8 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
9 | import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' | 9 | import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' |
10 | import { CONFIG } from '../../../initializers/constants' | 10 | import { CONFIG } from '../../../initializers/constants' |
11 | import { CONSTRAINTS_FIELDS } from '../../../initializers' | 11 | import { CONSTRAINTS_FIELDS } from '../../../initializers' |
12 | 12 | ||
13 | const videoImportAddValidator = getCommonVideoAttributes().concat([ | 13 | const videoImportAddValidator = getCommonVideoEditAttributes().concat([ |
14 | body('channelId') | 14 | body('channelId') |
15 | .toInt() | 15 | .toInt() |
16 | .custom(isIdValid).withMessage('Should have correct video channel id'), | 16 | .custom(isIdValid).withMessage('Should have correct video channel id'), |
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts new file mode 100644 index 000000000..ef8d0b851 --- /dev/null +++ b/server/middlewares/validators/videos/video-playlists.ts | |||
@@ -0,0 +1,302 @@ | |||
1 | import * as express from 'express' | ||
2 | import { body, param, ValidationChain } from 'express-validator/check' | ||
3 | import { UserRight, VideoPrivacy } from '../../../../shared' | ||
4 | import { logger } from '../../../helpers/logger' | ||
5 | import { UserModel } from '../../../models/account/user' | ||
6 | import { areValidationErrors } from '../utils' | ||
7 | import { isVideoExist, isVideoImage } from '../../../helpers/custom-validators/videos' | ||
8 | import { CONSTRAINTS_FIELDS } from '../../../initializers' | ||
9 | import { isIdOrUUIDValid, toValueOrNull } from '../../../helpers/custom-validators/misc' | ||
10 | import { | ||
11 | isVideoPlaylistDescriptionValid, | ||
12 | isVideoPlaylistExist, | ||
13 | isVideoPlaylistNameValid, | ||
14 | isVideoPlaylistPrivacyValid | ||
15 | } from '../../../helpers/custom-validators/video-playlists' | ||
16 | import { VideoPlaylistModel } from '../../../models/video/video-playlist' | ||
17 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | ||
18 | import { isVideoChannelIdExist } from '../../../helpers/custom-validators/video-channels' | ||
19 | import { VideoPlaylistElementModel } from '../../../models/video/video-playlist-element' | ||
20 | import { VideoModel } from '../../../models/video/video' | ||
21 | import { authenticatePromiseIfNeeded } from '../../oauth' | ||
22 | import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
23 | |||
24 | const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([ | ||
25 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
26 | logger.debug('Checking videoPlaylistsAddValidator parameters', { parameters: req.body }) | ||
27 | |||
28 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) | ||
29 | |||
30 | if (req.body.videoChannelId && !await isVideoChannelIdExist(req.body.videoChannelId, res)) return cleanUpReqFiles(req) | ||
31 | |||
32 | return next() | ||
33 | } | ||
34 | ]) | ||
35 | |||
36 | const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([ | ||
37 | param('playlistId') | ||
38 | .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), | ||
39 | |||
40 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
41 | logger.debug('Checking videoPlaylistsUpdateValidator parameters', { parameters: req.body }) | ||
42 | |||
43 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) | ||
44 | |||
45 | if (!await isVideoPlaylistExist(req.params.playlistId, res)) return cleanUpReqFiles(req) | ||
46 | if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, res.locals.videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) { | ||
47 | return cleanUpReqFiles(req) | ||
48 | } | ||
49 | |||
50 | if (req.body.videoChannelId && !await isVideoChannelIdExist(req.body.videoChannelId, res)) return cleanUpReqFiles(req) | ||
51 | |||
52 | return next() | ||
53 | } | ||
54 | ]) | ||
55 | |||
56 | const videoPlaylistsDeleteValidator = [ | ||
57 | param('playlistId') | ||
58 | .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), | ||
59 | |||
60 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
61 | logger.debug('Checking videoPlaylistsDeleteValidator parameters', { parameters: req.params }) | ||
62 | |||
63 | if (areValidationErrors(req, res)) return | ||
64 | |||
65 | if (!await isVideoPlaylistExist(req.params.playlistId, res)) return | ||
66 | if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, res.locals.videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) { | ||
67 | return | ||
68 | } | ||
69 | |||
70 | return next() | ||
71 | } | ||
72 | ] | ||
73 | |||
74 | const videoPlaylistsGetValidator = [ | ||
75 | param('playlistId') | ||
76 | .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), | ||
77 | |||
78 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
79 | logger.debug('Checking videoPlaylistsGetValidator parameters', { parameters: req.params }) | ||
80 | |||
81 | if (areValidationErrors(req, res)) return | ||
82 | |||
83 | if (!await isVideoPlaylistExist(req.params.playlistId, res)) return | ||
84 | |||
85 | const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist | ||
86 | if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { | ||
87 | await authenticatePromiseIfNeeded(req, res) | ||
88 | |||
89 | const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : null | ||
90 | |||
91 | if ( | ||
92 | !user || | ||
93 | (videoPlaylist.OwnerAccount.userId !== user.id && !user.hasRight(UserRight.UPDATE_ANY_VIDEO_PLAYLIST)) | ||
94 | ) { | ||
95 | return res.status(403) | ||
96 | .json({ error: 'Cannot get this private video playlist.' }) | ||
97 | } | ||
98 | |||
99 | return next() | ||
100 | } | ||
101 | |||
102 | return next() | ||
103 | } | ||
104 | ] | ||
105 | |||
106 | const videoPlaylistsAddVideoValidator = [ | ||
107 | param('playlistId') | ||
108 | .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), | ||
109 | body('videoId') | ||
110 | .custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'), | ||
111 | body('startTimestamp') | ||
112 | .optional() | ||
113 | .isInt({ min: 0 }).withMessage('Should have a valid start timestamp'), | ||
114 | body('stopTimestamp') | ||
115 | .optional() | ||
116 | .isInt({ min: 0 }).withMessage('Should have a valid stop timestamp'), | ||
117 | |||
118 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
119 | logger.debug('Checking videoPlaylistsAddVideoValidator parameters', { parameters: req.params }) | ||
120 | |||
121 | if (areValidationErrors(req, res)) return | ||
122 | |||
123 | if (!await isVideoPlaylistExist(req.params.playlistId, res)) return | ||
124 | if (!await isVideoExist(req.body.videoId, res, 'id')) return | ||
125 | |||
126 | const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist | ||
127 | const video: VideoModel = res.locals.video | ||
128 | |||
129 | const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideo(videoPlaylist.id, video.id) | ||
130 | if (videoPlaylistElement) { | ||
131 | res.status(409) | ||
132 | .json({ error: 'This video in this playlist already exists' }) | ||
133 | .end() | ||
134 | |||
135 | return | ||
136 | } | ||
137 | |||
138 | if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, res.locals.videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) { | ||
139 | return | ||
140 | } | ||
141 | |||
142 | return next() | ||
143 | } | ||
144 | ] | ||
145 | |||
146 | const videoPlaylistsUpdateOrRemoveVideoValidator = [ | ||
147 | param('playlistId') | ||
148 | .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), | ||
149 | param('videoId') | ||
150 | .custom(isIdOrUUIDValid).withMessage('Should have an video id/uuid'), | ||
151 | body('startTimestamp') | ||
152 | .optional() | ||
153 | .isInt({ min: 0 }).withMessage('Should have a valid start timestamp'), | ||
154 | body('stopTimestamp') | ||
155 | .optional() | ||
156 | .isInt({ min: 0 }).withMessage('Should have a valid stop timestamp'), | ||
157 | |||
158 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
159 | logger.debug('Checking videoPlaylistsRemoveVideoValidator parameters', { parameters: req.params }) | ||
160 | |||
161 | if (areValidationErrors(req, res)) return | ||
162 | |||
163 | if (!await isVideoPlaylistExist(req.params.playlistId, res)) return | ||
164 | if (!await isVideoExist(req.params.playlistId, res, 'id')) return | ||
165 | |||
166 | const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist | ||
167 | const video: VideoModel = res.locals.video | ||
168 | |||
169 | const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideo(videoPlaylist.id, video.id) | ||
170 | if (!videoPlaylistElement) { | ||
171 | res.status(404) | ||
172 | .json({ error: 'Video playlist element not found' }) | ||
173 | .end() | ||
174 | |||
175 | return | ||
176 | } | ||
177 | res.locals.videoPlaylistElement = videoPlaylistElement | ||
178 | |||
179 | if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) return | ||
180 | |||
181 | return next() | ||
182 | } | ||
183 | ] | ||
184 | |||
185 | const videoPlaylistElementAPGetValidator = [ | ||
186 | param('playlistId') | ||
187 | .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), | ||
188 | param('videoId') | ||
189 | .custom(isIdOrUUIDValid).withMessage('Should have an video id/uuid'), | ||
190 | |||
191 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
192 | logger.debug('Checking videoPlaylistElementAPGetValidator parameters', { parameters: req.params }) | ||
193 | |||
194 | if (areValidationErrors(req, res)) return | ||
195 | |||
196 | const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideoForAP(req.params.playlistId, req.params.videoId) | ||
197 | if (!videoPlaylistElement) { | ||
198 | res.status(404) | ||
199 | .json({ error: 'Video playlist element not found' }) | ||
200 | .end() | ||
201 | |||
202 | return | ||
203 | } | ||
204 | |||
205 | if (videoPlaylistElement.VideoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { | ||
206 | return res.status(403).end() | ||
207 | } | ||
208 | |||
209 | res.locals.videoPlaylistElement = videoPlaylistElement | ||
210 | |||
211 | return next() | ||
212 | } | ||
213 | ] | ||
214 | |||
215 | const videoPlaylistsReorderVideosValidator = [ | ||
216 | param('playlistId') | ||
217 | .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), | ||
218 | body('startPosition') | ||
219 | .isInt({ min: 1 }).withMessage('Should have a valid start position'), | ||
220 | body('insertAfterPosition') | ||
221 | .isInt({ min: 0 }).withMessage('Should have a valid insert after position'), | ||
222 | body('reorderLength') | ||
223 | .optional() | ||
224 | .isInt({ min: 1 }).withMessage('Should have a valid range length'), | ||
225 | |||
226 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
227 | logger.debug('Checking videoPlaylistsReorderVideosValidator parameters', { parameters: req.params }) | ||
228 | |||
229 | if (areValidationErrors(req, res)) return | ||
230 | |||
231 | if (!await isVideoPlaylistExist(req.params.playlistId, res)) return | ||
232 | |||
233 | const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist | ||
234 | if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) return | ||
235 | |||
236 | return next() | ||
237 | } | ||
238 | ] | ||
239 | |||
240 | // --------------------------------------------------------------------------- | ||
241 | |||
242 | export { | ||
243 | videoPlaylistsAddValidator, | ||
244 | videoPlaylistsUpdateValidator, | ||
245 | videoPlaylistsDeleteValidator, | ||
246 | videoPlaylistsGetValidator, | ||
247 | |||
248 | videoPlaylistsAddVideoValidator, | ||
249 | videoPlaylistsUpdateOrRemoveVideoValidator, | ||
250 | videoPlaylistsReorderVideosValidator, | ||
251 | |||
252 | videoPlaylistElementAPGetValidator | ||
253 | } | ||
254 | |||
255 | // --------------------------------------------------------------------------- | ||
256 | |||
257 | function getCommonPlaylistEditAttributes () { | ||
258 | return [ | ||
259 | body('thumbnailfile') | ||
260 | .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( | ||
261 | 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' | ||
262 | + CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ') | ||
263 | ), | ||
264 | |||
265 | body('displayName') | ||
266 | .custom(isVideoPlaylistNameValid).withMessage('Should have a valid display name'), | ||
267 | body('description') | ||
268 | .optional() | ||
269 | .customSanitizer(toValueOrNull) | ||
270 | .custom(isVideoPlaylistDescriptionValid).withMessage('Should have a valid description'), | ||
271 | body('privacy') | ||
272 | .optional() | ||
273 | .toInt() | ||
274 | .custom(isVideoPlaylistPrivacyValid).withMessage('Should have correct playlist privacy'), | ||
275 | body('videoChannelId') | ||
276 | .optional() | ||
277 | .toInt() | ||
278 | ] as (ValidationChain | express.Handler)[] | ||
279 | } | ||
280 | |||
281 | function checkUserCanManageVideoPlaylist (user: UserModel, videoPlaylist: VideoPlaylistModel, right: UserRight, res: express.Response) { | ||
282 | if (videoPlaylist.isOwned() === false) { | ||
283 | res.status(403) | ||
284 | .json({ error: 'Cannot manage video playlist of another server.' }) | ||
285 | .end() | ||
286 | |||
287 | return false | ||
288 | } | ||
289 | |||
290 | // Check if the user can manage the video playlist | ||
291 | // The user can delete it if s/he is an admin | ||
292 | // Or if s/he is the video playlist's owner | ||
293 | if (user.hasRight(right) === false && videoPlaylist.ownerAccountId !== user.Account.id) { | ||
294 | res.status(403) | ||
295 | .json({ error: 'Cannot manage video playlist of another user' }) | ||
296 | .end() | ||
297 | |||
298 | return false | ||
299 | } | ||
300 | |||
301 | return true | ||
302 | } | ||
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index 159727e28..a5e3ed0dc 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts | |||
@@ -46,7 +46,7 @@ import { VideoFetchType } from '../../../helpers/video' | |||
46 | import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' | 46 | import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' |
47 | import { getServerActor } from '../../../helpers/utils' | 47 | import { getServerActor } from '../../../helpers/utils' |
48 | 48 | ||
49 | const videosAddValidator = getCommonVideoAttributes().concat([ | 49 | const videosAddValidator = getCommonVideoEditAttributes().concat([ |
50 | body('videofile') | 50 | body('videofile') |
51 | .custom((value, { req }) => isVideoFile(req.files)).withMessage( | 51 | .custom((value, { req }) => isVideoFile(req.files)).withMessage( |
52 | 'This file is not supported or too large. Please, make sure it is of the following type: ' | 52 | 'This file is not supported or too large. Please, make sure it is of the following type: ' |
@@ -94,7 +94,7 @@ const videosAddValidator = getCommonVideoAttributes().concat([ | |||
94 | } | 94 | } |
95 | ]) | 95 | ]) |
96 | 96 | ||
97 | const videosUpdateValidator = getCommonVideoAttributes().concat([ | 97 | const videosUpdateValidator = getCommonVideoEditAttributes().concat([ |
98 | param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), | 98 | param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), |
99 | body('name') | 99 | body('name') |
100 | .optional() | 100 | .optional() |
@@ -288,7 +288,7 @@ const videosAcceptChangeOwnershipValidator = [ | |||
288 | } | 288 | } |
289 | ] | 289 | ] |
290 | 290 | ||
291 | function getCommonVideoAttributes () { | 291 | function getCommonVideoEditAttributes () { |
292 | return [ | 292 | return [ |
293 | body('thumbnailfile') | 293 | body('thumbnailfile') |
294 | .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( | 294 | .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( |
@@ -421,7 +421,7 @@ export { | |||
421 | videosTerminateChangeOwnershipValidator, | 421 | videosTerminateChangeOwnershipValidator, |
422 | videosAcceptChangeOwnershipValidator, | 422 | videosAcceptChangeOwnershipValidator, |
423 | 423 | ||
424 | getCommonVideoAttributes, | 424 | getCommonVideoEditAttributes, |
425 | 425 | ||
426 | commonVideosFiltersValidator | 426 | commonVideosFiltersValidator |
427 | } | 427 | } |
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 | } |
diff --git a/server/tests/api/check-params/video-playlists.ts b/server/tests/api/check-params/video-playlists.ts new file mode 100644 index 000000000..7004badac --- /dev/null +++ b/server/tests/api/check-params/video-playlists.ts | |||
@@ -0,0 +1,117 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import { omit } from 'lodash' | ||
4 | import 'mocha' | ||
5 | import { join } from 'path' | ||
6 | import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum' | ||
7 | import { | ||
8 | createUser, | ||
9 | flushTests, | ||
10 | getMyUserInformation, | ||
11 | immutableAssign, | ||
12 | killallServers, | ||
13 | makeGetRequest, | ||
14 | makePostBodyRequest, | ||
15 | makeUploadRequest, | ||
16 | runServer, | ||
17 | ServerInfo, | ||
18 | setAccessTokensToServers, | ||
19 | updateCustomSubConfig, | ||
20 | userLogin | ||
21 | } from '../../../../shared/utils' | ||
22 | import { | ||
23 | checkBadCountPagination, | ||
24 | checkBadSortPagination, | ||
25 | checkBadStartPagination | ||
26 | } from '../../../../shared/utils/requests/check-api-params' | ||
27 | import { getMagnetURI, getYoutubeVideoUrl } from '../../../../shared/utils/videos/video-imports' | ||
28 | |||
29 | describe('Test video playlists API validator', function () { | ||
30 | const path = '/api/v1/videos/video-playlists' | ||
31 | let server: ServerInfo | ||
32 | let userAccessToken = '' | ||
33 | |||
34 | // --------------------------------------------------------------- | ||
35 | |||
36 | before(async function () { | ||
37 | this.timeout(30000) | ||
38 | |||
39 | await flushTests() | ||
40 | |||
41 | server = await runServer(1) | ||
42 | |||
43 | await setAccessTokensToServers([ server ]) | ||
44 | |||
45 | const username = 'user1' | ||
46 | const password = 'my super password' | ||
47 | await createUser(server.url, server.accessToken, username, password) | ||
48 | userAccessToken = await userLogin(server, { username, password }) | ||
49 | }) | ||
50 | |||
51 | describe('When listing video playlists', function () { | ||
52 | const globalPath = '/api/v1/video-playlists' | ||
53 | const accountPath = '/api/v1/accounts/root/video-playlists' | ||
54 | const videoChannelPath = '/api/v1/video-channels/root_channel/video-playlists' | ||
55 | |||
56 | it('Should fail with a bad start pagination', async function () { | ||
57 | await checkBadStartPagination(server.url, globalPath, server.accessToken) | ||
58 | await checkBadStartPagination(server.url, accountPath, server.accessToken) | ||
59 | await checkBadStartPagination(server.url, videoChannelPath, server.accessToken) | ||
60 | }) | ||
61 | |||
62 | it('Should fail with a bad count pagination', async function () { | ||
63 | await checkBadCountPagination(server.url, globalPath, server.accessToken) | ||
64 | await checkBadCountPagination(server.url, accountPath, server.accessToken) | ||
65 | await checkBadCountPagination(server.url, videoChannelPath, server.accessToken) | ||
66 | }) | ||
67 | |||
68 | it('Should fail with an incorrect sort', async function () { | ||
69 | await checkBadSortPagination(server.url, globalPath, server.accessToken) | ||
70 | await checkBadSortPagination(server.url, accountPath, server.accessToken) | ||
71 | await checkBadSortPagination(server.url, videoChannelPath, server.accessToken) | ||
72 | }) | ||
73 | |||
74 | it('Should fail with a bad account parameter', async function () { | ||
75 | const accountPath = '/api/v1/accounts/root2/video-playlists' | ||
76 | |||
77 | await makeGetRequest({ url: server.url, path: accountPath, statusCodeExpected: 404, token: server.accessToken }) | ||
78 | }) | ||
79 | |||
80 | it('Should fail with a bad video channel parameter', async function () { | ||
81 | const accountPath = '/api/v1/video-channels/bad_channel/video-playlists' | ||
82 | |||
83 | await makeGetRequest({ url: server.url, path: accountPath, statusCodeExpected: 404, token: server.accessToken }) | ||
84 | }) | ||
85 | |||
86 | it('Should success with the correct parameters', async function () { | ||
87 | await makeGetRequest({ url: server.url, path: globalPath, statusCodeExpected: 200, token: server.accessToken }) | ||
88 | await makeGetRequest({ url: server.url, path: accountPath, statusCodeExpected: 200, token: server.accessToken }) | ||
89 | await makeGetRequest({ url: server.url, path: videoChannelPath, statusCodeExpected: 200, token: server.accessToken }) | ||
90 | }) | ||
91 | }) | ||
92 | |||
93 | describe('When listing videos of a playlist', async function () { | ||
94 | const path = '/api/v1/video-playlists' | ||
95 | |||
96 | it('Should fail with a bad start pagination', async function () { | ||
97 | await checkBadStartPagination(server.url, path, server.accessToken) | ||
98 | }) | ||
99 | |||
100 | it('Should fail with a bad count pagination', async function () { | ||
101 | await checkBadCountPagination(server.url, path, server.accessToken) | ||
102 | }) | ||
103 | |||
104 | it('Should fail with an incorrect sort', async function () { | ||
105 | await checkBadSortPagination(server.url, path, server.accessToken) | ||
106 | }) | ||
107 | }) | ||
108 | |||
109 | after(async function () { | ||
110 | killallServers([ server ]) | ||
111 | |||
112 | // Keep the logs if the test failed | ||
113 | if (this['ok']) { | ||
114 | await flushTests() | ||
115 | } | ||
116 | }) | ||
117 | }) | ||
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts new file mode 100644 index 000000000..cb23239da --- /dev/null +++ b/server/tests/api/videos/video-playlists.ts | |||
@@ -0,0 +1,161 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { join } from 'path' | ||
6 | import * as request from 'supertest' | ||
7 | import { VideoPrivacy } from '../../../../shared/models/videos' | ||
8 | import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model' | ||
9 | import { | ||
10 | addVideoChannel, | ||
11 | checkTmpIsEmpty, | ||
12 | checkVideoFilesWereRemoved, | ||
13 | completeVideoCheck, | ||
14 | createUser, | ||
15 | dateIsValid, | ||
16 | doubleFollow, | ||
17 | flushAndRunMultipleServers, | ||
18 | flushTests, | ||
19 | getLocalVideos, | ||
20 | getVideo, | ||
21 | getVideoChannelsList, | ||
22 | getVideosList, | ||
23 | killallServers, | ||
24 | rateVideo, | ||
25 | removeVideo, | ||
26 | ServerInfo, | ||
27 | setAccessTokensToServers, | ||
28 | testImage, | ||
29 | updateVideo, | ||
30 | uploadVideo, | ||
31 | userLogin, | ||
32 | viewVideo, | ||
33 | wait, | ||
34 | webtorrentAdd | ||
35 | } from '../../../../shared/utils' | ||
36 | import { | ||
37 | addVideoCommentReply, | ||
38 | addVideoCommentThread, | ||
39 | deleteVideoComment, | ||
40 | getVideoCommentThreads, | ||
41 | getVideoThreadComments | ||
42 | } from '../../../../shared/utils/videos/video-comments' | ||
43 | import { waitJobs } from '../../../../shared/utils/server/jobs' | ||
44 | |||
45 | const expect = chai.expect | ||
46 | |||
47 | describe('Test video playlists', function () { | ||
48 | let servers: ServerInfo[] = [] | ||
49 | |||
50 | before(async function () { | ||
51 | this.timeout(120000) | ||
52 | |||
53 | servers = await flushAndRunMultipleServers(3) | ||
54 | |||
55 | // Get the access tokens | ||
56 | await setAccessTokensToServers(servers) | ||
57 | |||
58 | // Server 1 and server 2 follow each other | ||
59 | await doubleFollow(servers[0], servers[1]) | ||
60 | // Server 1 and server 3 follow each other | ||
61 | await doubleFollow(servers[0], servers[2]) | ||
62 | }) | ||
63 | |||
64 | it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () { | ||
65 | |||
66 | }) | ||
67 | |||
68 | it('Should create a playlist on server 2 and have the playlist on server 1 but not on server 3', async function () { | ||
69 | // create 2 playlists (with videos and no videos) | ||
70 | // With thumbnail and no thumbnail | ||
71 | }) | ||
72 | |||
73 | it('Should have the playlist on server 3 after a new follow', async function () { | ||
74 | // Server 2 and server 3 follow each other | ||
75 | await doubleFollow(servers[1], servers[2]) | ||
76 | }) | ||
77 | |||
78 | it('Should create some playlists and list them correctly', async function () { | ||
79 | // create 3 playlists with some videos in it | ||
80 | // check pagination | ||
81 | // check sort | ||
82 | // check empty | ||
83 | }) | ||
84 | |||
85 | it('Should list video channel playlists', async function () { | ||
86 | // check pagination | ||
87 | // check sort | ||
88 | // check empty | ||
89 | }) | ||
90 | |||
91 | it('Should list account playlists', async function () { | ||
92 | // check pagination | ||
93 | // check sort | ||
94 | // check empty | ||
95 | }) | ||
96 | |||
97 | it('Should get a playlist', async function () { | ||
98 | // get empty playlist | ||
99 | // get non empty playlist | ||
100 | }) | ||
101 | |||
102 | it('Should update a playlist', async function () { | ||
103 | // update thumbnail | ||
104 | |||
105 | // update other details | ||
106 | }) | ||
107 | |||
108 | it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () { | ||
109 | |||
110 | }) | ||
111 | |||
112 | it('Should correctly list playlist videos', async function () { | ||
113 | // empty | ||
114 | // some filters? | ||
115 | }) | ||
116 | |||
117 | it('Should reorder the playlist', async function () { | ||
118 | // reorder 1 element | ||
119 | // reorder 3 elements | ||
120 | // reorder at the beginning | ||
121 | // reorder at the end | ||
122 | // reorder before/after | ||
123 | }) | ||
124 | |||
125 | it('Should update startTimestamp/endTimestamp of some elements', async function () { | ||
126 | |||
127 | }) | ||
128 | |||
129 | it('Should delete some elements', async function () { | ||
130 | |||
131 | }) | ||
132 | |||
133 | it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () { | ||
134 | |||
135 | }) | ||
136 | |||
137 | it('Should have deleted the thumbnail on server 1, 2 and 3', async function () { | ||
138 | |||
139 | }) | ||
140 | |||
141 | it('Should unfollow servers 1 and 2 and hide their playlists', async function () { | ||
142 | |||
143 | }) | ||
144 | |||
145 | it('Should delete a channel and remove the associated playlist', async function () { | ||
146 | |||
147 | }) | ||
148 | |||
149 | it('Should delete an account and delete its playlists', async function () { | ||
150 | |||
151 | }) | ||
152 | |||
153 | after(async function () { | ||
154 | killallServers(servers) | ||
155 | |||
156 | // Keep the logs if the test failed | ||
157 | if (this['ok']) { | ||
158 | await flushTests() | ||
159 | } | ||
160 | }) | ||
161 | }) | ||
diff --git a/shared/models/activitypub/activity.ts b/shared/models/activitypub/activity.ts index 89994f665..95801190d 100644 --- a/shared/models/activitypub/activity.ts +++ b/shared/models/activitypub/activity.ts | |||
@@ -6,6 +6,7 @@ import { VideoAbuseObject } from './objects/video-abuse-object' | |||
6 | import { VideoCommentObject } from './objects/video-comment-object' | 6 | import { VideoCommentObject } from './objects/video-comment-object' |
7 | import { ViewObject } from './objects/view-object' | 7 | import { ViewObject } from './objects/view-object' |
8 | import { APObject } from './objects/object.model' | 8 | import { APObject } from './objects/object.model' |
9 | import { PlaylistObject } from './objects/playlist-object' | ||
9 | 10 | ||
10 | export type Activity = ActivityCreate | ActivityUpdate | | 11 | export type Activity = ActivityCreate | ActivityUpdate | |
11 | ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce | | 12 | ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce | |
@@ -31,12 +32,12 @@ export interface BaseActivity { | |||
31 | 32 | ||
32 | export interface ActivityCreate extends BaseActivity { | 33 | export interface ActivityCreate extends BaseActivity { |
33 | type: 'Create' | 34 | type: 'Create' |
34 | object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | 35 | object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject |
35 | } | 36 | } |
36 | 37 | ||
37 | export interface ActivityUpdate extends BaseActivity { | 38 | export interface ActivityUpdate extends BaseActivity { |
38 | type: 'Update' | 39 | type: 'Update' |
39 | object: VideoTorrentObject | ActivityPubActor | CacheFileObject | 40 | object: VideoTorrentObject | ActivityPubActor | CacheFileObject | PlaylistObject |
40 | } | 41 | } |
41 | 42 | ||
42 | export interface ActivityDelete extends BaseActivity { | 43 | export interface ActivityDelete extends BaseActivity { |
diff --git a/shared/models/activitypub/activitypub-actor.ts b/shared/models/activitypub/activitypub-actor.ts index 119bc22d4..5e30bf783 100644 --- a/shared/models/activitypub/activitypub-actor.ts +++ b/shared/models/activitypub/activitypub-actor.ts | |||
@@ -8,6 +8,7 @@ export interface ActivityPubActor { | |||
8 | id: string | 8 | id: string |
9 | following: string | 9 | following: string |
10 | followers: string | 10 | followers: string |
11 | playlists?: string | ||
11 | inbox: string | 12 | inbox: string |
12 | outbox: string | 13 | outbox: string |
13 | preferredUsername: string | 14 | preferredUsername: string |
diff --git a/shared/models/activitypub/objects/playlist-element-object.ts b/shared/models/activitypub/objects/playlist-element-object.ts new file mode 100644 index 000000000..b85e4fe19 --- /dev/null +++ b/shared/models/activitypub/objects/playlist-element-object.ts | |||
@@ -0,0 +1,10 @@ | |||
1 | export interface PlaylistElementObject { | ||
2 | id: string | ||
3 | type: 'PlaylistElement' | ||
4 | |||
5 | url: string | ||
6 | position: number | ||
7 | |||
8 | startTimestamp?: number | ||
9 | stopTimestamp?: number | ||
10 | } | ||
diff --git a/shared/models/activitypub/objects/playlist-object.ts b/shared/models/activitypub/objects/playlist-object.ts new file mode 100644 index 000000000..5f6733f92 --- /dev/null +++ b/shared/models/activitypub/objects/playlist-object.ts | |||
@@ -0,0 +1,23 @@ | |||
1 | import { ActivityIconObject } from './common-objects' | ||
2 | |||
3 | export interface PlaylistObject { | ||
4 | id: string | ||
5 | type: 'Playlist' | ||
6 | |||
7 | name: string | ||
8 | content: string | ||
9 | uuid: string | ||
10 | |||
11 | totalItems: number | ||
12 | attributedTo: string[] | ||
13 | |||
14 | icon: ActivityIconObject | ||
15 | |||
16 | orderedItems?: string[] | ||
17 | |||
18 | partOf?: string | ||
19 | next?: string | ||
20 | first?: string | ||
21 | |||
22 | to?: string[] | ||
23 | } | ||
diff --git a/shared/models/actors/account.model.ts b/shared/models/actors/account.model.ts index 7f1dbbc37..043a2507e 100644 --- a/shared/models/actors/account.model.ts +++ b/shared/models/actors/account.model.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | import { Actor } from './actor.model' | 1 | import { Actor } from './actor.model' |
2 | import { Avatar } from '../avatars' | ||
2 | 3 | ||
3 | export interface Account extends Actor { | 4 | export interface Account extends Actor { |
4 | displayName: string | 5 | displayName: string |
@@ -6,3 +7,13 @@ export interface Account extends Actor { | |||
6 | 7 | ||
7 | userId?: number | 8 | userId?: number |
8 | } | 9 | } |
10 | |||
11 | export interface AccountSummary { | ||
12 | id: number | ||
13 | uuid: string | ||
14 | name: string | ||
15 | displayName: string | ||
16 | url: string | ||
17 | host: string | ||
18 | avatar?: Avatar | ||
19 | } | ||
diff --git a/shared/models/overviews/videos-overview.ts b/shared/models/overviews/videos-overview.ts index ee009d94c..e725f166b 100644 --- a/shared/models/overviews/videos-overview.ts +++ b/shared/models/overviews/videos-overview.ts | |||
@@ -1,8 +1,8 @@ | |||
1 | import { Video, VideoChannelAttribute, VideoConstant } from '../videos' | 1 | import { Video, VideoChannelSummary, VideoConstant } from '../videos' |
2 | 2 | ||
3 | export interface VideosOverview { | 3 | export interface VideosOverview { |
4 | channels: { | 4 | channels: { |
5 | channel: VideoChannelAttribute | 5 | channel: VideoChannelSummary |
6 | videos: Video[] | 6 | videos: Video[] |
7 | }[] | 7 | }[] |
8 | 8 | ||
diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts index 090256bca..eaa064bd9 100644 --- a/shared/models/users/user-right.enum.ts +++ b/shared/models/users/user-right.enum.ts | |||
@@ -20,8 +20,12 @@ export enum UserRight { | |||
20 | 20 | ||
21 | REMOVE_ANY_VIDEO, | 21 | REMOVE_ANY_VIDEO, |
22 | REMOVE_ANY_VIDEO_CHANNEL, | 22 | REMOVE_ANY_VIDEO_CHANNEL, |
23 | REMOVE_ANY_VIDEO_PLAYLIST, | ||
23 | REMOVE_ANY_VIDEO_COMMENT, | 24 | REMOVE_ANY_VIDEO_COMMENT, |
25 | |||
24 | UPDATE_ANY_VIDEO, | 26 | UPDATE_ANY_VIDEO, |
27 | UPDATE_ANY_VIDEO_PLAYLIST, | ||
28 | |||
25 | SEE_ALL_VIDEOS, | 29 | SEE_ALL_VIDEOS, |
26 | CHANGE_VIDEO_OWNERSHIP | 30 | CHANGE_VIDEO_OWNERSHIP |
27 | } | 31 | } |
diff --git a/shared/models/users/user-role.ts b/shared/models/users/user-role.ts index 59c2ba106..0b6554e51 100644 --- a/shared/models/users/user-role.ts +++ b/shared/models/users/user-role.ts | |||
@@ -25,6 +25,7 @@ const userRoleRights: { [ id: number ]: UserRight[] } = { | |||
25 | UserRight.MANAGE_VIDEO_ABUSES, | 25 | UserRight.MANAGE_VIDEO_ABUSES, |
26 | UserRight.REMOVE_ANY_VIDEO, | 26 | UserRight.REMOVE_ANY_VIDEO, |
27 | UserRight.REMOVE_ANY_VIDEO_CHANNEL, | 27 | UserRight.REMOVE_ANY_VIDEO_CHANNEL, |
28 | UserRight.REMOVE_ANY_VIDEO_PLAYLIST, | ||
28 | UserRight.REMOVE_ANY_VIDEO_COMMENT, | 29 | UserRight.REMOVE_ANY_VIDEO_COMMENT, |
29 | UserRight.UPDATE_ANY_VIDEO, | 30 | UserRight.UPDATE_ANY_VIDEO, |
30 | UserRight.SEE_ALL_VIDEOS, | 31 | UserRight.SEE_ALL_VIDEOS, |
diff --git a/shared/models/videos/channel/video-channel.model.ts b/shared/models/videos/channel/video-channel.model.ts index 92918f66c..14a813f8f 100644 --- a/shared/models/videos/channel/video-channel.model.ts +++ b/shared/models/videos/channel/video-channel.model.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { Actor } from '../../actors/actor.model' | 1 | import { Actor } from '../../actors/actor.model' |
2 | import { Video } from '../video.model' | ||
3 | import { Account } from '../../actors/index' | 2 | import { Account } from '../../actors/index' |
3 | import { Avatar } from '../../avatars' | ||
4 | 4 | ||
5 | export interface VideoChannel extends Actor { | 5 | export interface VideoChannel extends Actor { |
6 | displayName: string | 6 | displayName: string |
@@ -9,3 +9,13 @@ export interface VideoChannel extends Actor { | |||
9 | isLocal: boolean | 9 | isLocal: boolean |
10 | ownerAccount?: Account | 10 | ownerAccount?: Account |
11 | } | 11 | } |
12 | |||
13 | export interface VideoChannelSummary { | ||
14 | id: number | ||
15 | uuid: string | ||
16 | name: string | ||
17 | displayName: string | ||
18 | url: string | ||
19 | host: string | ||
20 | avatar?: Avatar | ||
21 | } | ||
diff --git a/shared/models/videos/playlist/video-playlist-create.model.ts b/shared/models/videos/playlist/video-playlist-create.model.ts new file mode 100644 index 000000000..386acbb96 --- /dev/null +++ b/shared/models/videos/playlist/video-playlist-create.model.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | import { VideoPlaylistPrivacy } from './video-playlist-privacy.model' | ||
2 | |||
3 | export interface VideoPlaylistCreate { | ||
4 | displayName: string | ||
5 | description: string | ||
6 | privacy: VideoPlaylistPrivacy | ||
7 | |||
8 | videoChannelId?: number | ||
9 | |||
10 | thumbnailfile?: Blob | ||
11 | } | ||
diff --git a/shared/models/videos/playlist/video-playlist-element-create.model.ts b/shared/models/videos/playlist/video-playlist-element-create.model.ts new file mode 100644 index 000000000..9bd56a8ca --- /dev/null +++ b/shared/models/videos/playlist/video-playlist-element-create.model.ts | |||
@@ -0,0 +1,4 @@ | |||
1 | export interface VideoPlaylistElementCreate { | ||
2 | startTimestamp?: number | ||
3 | stopTimestamp?: number | ||
4 | } | ||
diff --git a/shared/models/videos/playlist/video-playlist-element-update.model.ts b/shared/models/videos/playlist/video-playlist-element-update.model.ts new file mode 100644 index 000000000..15a30fbdc --- /dev/null +++ b/shared/models/videos/playlist/video-playlist-element-update.model.ts | |||
@@ -0,0 +1,4 @@ | |||
1 | export interface VideoPlaylistElementUpdate { | ||
2 | startTimestamp?: number | ||
3 | stopTimestamp?: number | ||
4 | } | ||
diff --git a/shared/models/videos/playlist/video-playlist-privacy.model.ts b/shared/models/videos/playlist/video-playlist-privacy.model.ts new file mode 100644 index 000000000..96e5e2211 --- /dev/null +++ b/shared/models/videos/playlist/video-playlist-privacy.model.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export enum VideoPlaylistPrivacy { | ||
2 | PUBLIC = 1, | ||
3 | UNLISTED = 2, | ||
4 | PRIVATE = 3 | ||
5 | } | ||
diff --git a/shared/models/videos/playlist/video-playlist-update.model.ts b/shared/models/videos/playlist/video-playlist-update.model.ts new file mode 100644 index 000000000..c7a15c550 --- /dev/null +++ b/shared/models/videos/playlist/video-playlist-update.model.ts | |||
@@ -0,0 +1,10 @@ | |||
1 | import { VideoPlaylistPrivacy } from './video-playlist-privacy.model' | ||
2 | |||
3 | export interface VideoPlaylistUpdate { | ||
4 | displayName: string | ||
5 | description: string | ||
6 | privacy: VideoPlaylistPrivacy | ||
7 | |||
8 | videoChannelId?: number | ||
9 | thumbnailfile?: Blob | ||
10 | } | ||
diff --git a/shared/models/videos/playlist/video-playlist.model.ts b/shared/models/videos/playlist/video-playlist.model.ts new file mode 100644 index 000000000..6aa04048c --- /dev/null +++ b/shared/models/videos/playlist/video-playlist.model.ts | |||
@@ -0,0 +1,23 @@ | |||
1 | import { AccountSummary } from '../../actors/index' | ||
2 | import { VideoChannelSummary, VideoConstant } from '..' | ||
3 | import { VideoPlaylistPrivacy } from './video-playlist-privacy.model' | ||
4 | |||
5 | export interface VideoPlaylist { | ||
6 | id: number | ||
7 | uuid: string | ||
8 | isLocal: boolean | ||
9 | |||
10 | displayName: string | ||
11 | description: string | ||
12 | privacy: VideoConstant<VideoPlaylistPrivacy> | ||
13 | |||
14 | thumbnailPath: string | ||
15 | |||
16 | videosLength: number | ||
17 | |||
18 | createdAt: Date | string | ||
19 | updatedAt: Date | string | ||
20 | |||
21 | ownerAccount?: AccountSummary | ||
22 | videoChannel?: VideoChannelSummary | ||
23 | } | ||
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index df800461c..6e7a6831e 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { VideoResolution, VideoState } from '../../index' | 1 | import { AccountSummary, VideoChannelSummary, VideoResolution, VideoState } from '../../index' |
2 | import { Account } from '../actors' | 2 | import { Account } from '../actors' |
3 | import { Avatar } from '../avatars/avatar.model' | 3 | import { Avatar } from '../avatars/avatar.model' |
4 | import { VideoChannel } from './channel/video-channel.model' | 4 | import { VideoChannel } from './channel/video-channel.model' |
@@ -18,26 +18,6 @@ export interface VideoFile { | |||
18 | fps: number | 18 | fps: number |
19 | } | 19 | } |
20 | 20 | ||
21 | export interface VideoChannelAttribute { | ||
22 | id: number | ||
23 | uuid: string | ||
24 | name: string | ||
25 | displayName: string | ||
26 | url: string | ||
27 | host: string | ||
28 | avatar?: Avatar | ||
29 | } | ||
30 | |||
31 | export interface AccountAttribute { | ||
32 | id: number | ||
33 | uuid: string | ||
34 | name: string | ||
35 | displayName: string | ||
36 | url: string | ||
37 | host: string | ||
38 | avatar?: Avatar | ||
39 | } | ||
40 | |||
41 | export interface Video { | 21 | export interface Video { |
42 | id: number | 22 | id: number |
43 | uuid: string | 23 | uuid: string |
@@ -68,12 +48,18 @@ export interface Video { | |||
68 | blacklisted?: boolean | 48 | blacklisted?: boolean |
69 | blacklistedReason?: string | 49 | blacklistedReason?: string |
70 | 50 | ||
71 | account: AccountAttribute | 51 | account: AccountSummary |
72 | channel: VideoChannelAttribute | 52 | channel: VideoChannelSummary |
73 | 53 | ||
74 | userHistory?: { | 54 | userHistory?: { |
75 | currentTime: number | 55 | currentTime: number |
76 | } | 56 | } |
57 | |||
58 | playlistElement?: { | ||
59 | position: number | ||
60 | startTimestamp: number | ||
61 | stopTimestamp: number | ||
62 | } | ||
77 | } | 63 | } |
78 | 64 | ||
79 | export interface VideoDetails extends Video { | 65 | export interface VideoDetails extends Video { |
diff --git a/shared/utils/videos/video-playlists.ts b/shared/utils/videos/video-playlists.ts index eb25011cb..5186d9c4f 100644 --- a/shared/utils/videos/video-playlists.ts +++ b/shared/utils/videos/video-playlists.ts | |||
@@ -1,51 +1,185 @@ | |||
1 | import { makeRawRequest } from '../requests/requests' | 1 | import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest, makeUploadRequest } from '../requests/requests' |
2 | import { sha256 } from '../../../server/helpers/core-utils' | 2 | import { VideoPlaylistCreate } from '../../models/videos/playlist/video-playlist-create.model' |
3 | import { VideoStreamingPlaylist } from '../../models/videos/video-streaming-playlist.model' | 3 | import { omit } from 'lodash' |
4 | import { expect } from 'chai' | 4 | import { VideoPlaylistUpdate } from '../../models/videos/playlist/video-playlist-update.model' |
5 | import { VideoPlaylistElementCreate } from '../../models/videos/playlist/video-playlist-element-create.model' | ||
6 | import { VideoPlaylistElementUpdate } from '../../models/videos/playlist/video-playlist-element-update.model' | ||
5 | 7 | ||
6 | function getPlaylist (url: string, statusCodeExpected = 200) { | 8 | function getVideoPlaylistsList (url: string, start: number, count: number, sort?: string) { |
7 | return makeRawRequest(url, statusCodeExpected) | 9 | const path = '/api/v1/video-playlists' |
10 | |||
11 | const query = { | ||
12 | start, | ||
13 | count, | ||
14 | sort | ||
15 | } | ||
16 | |||
17 | return makeGetRequest({ | ||
18 | url, | ||
19 | path, | ||
20 | query | ||
21 | }) | ||
8 | } | 22 | } |
9 | 23 | ||
10 | function getSegment (url: string, statusCodeExpected = 200, range?: string) { | 24 | function getVideoPlaylist (url: string, playlistId: number | string, statusCodeExpected = 200) { |
11 | return makeRawRequest(url, statusCodeExpected, range) | 25 | const path = '/api/v1/video-playlists/' + playlistId |
26 | |||
27 | return makeGetRequest({ | ||
28 | url, | ||
29 | path, | ||
30 | statusCodeExpected | ||
31 | }) | ||
12 | } | 32 | } |
13 | 33 | ||
14 | function getSegmentSha256 (url: string, statusCodeExpected = 200) { | 34 | function deleteVideoPlaylist (url: string, token: string, playlistId: number | string, statusCodeExpected = 200) { |
15 | return makeRawRequest(url, statusCodeExpected) | 35 | const path = '/api/v1/video-playlists/' + playlistId |
36 | |||
37 | return makeDeleteRequest({ | ||
38 | url, | ||
39 | path, | ||
40 | token, | ||
41 | statusCodeExpected | ||
42 | }) | ||
16 | } | 43 | } |
17 | 44 | ||
18 | async function checkSegmentHash ( | 45 | function createVideoPlaylist (options: { |
19 | baseUrlPlaylist: string, | 46 | url: string, |
20 | baseUrlSegment: string, | 47 | token: string, |
21 | videoUUID: string, | 48 | playlistAttrs: VideoPlaylistCreate, |
22 | resolution: number, | 49 | expectedStatus: number |
23 | hlsPlaylist: VideoStreamingPlaylist | 50 | }) { |
24 | ) { | 51 | const path = '/api/v1/video-playlists/' |
25 | const res = await getPlaylist(`${baseUrlPlaylist}/${videoUUID}/${resolution}.m3u8`) | 52 | |
26 | const playlist = res.text | 53 | const fields = omit(options.playlistAttrs, 'thumbnailfile') |
27 | 54 | ||
28 | const videoName = `${videoUUID}-${resolution}-fragmented.mp4` | 55 | const attaches = options.playlistAttrs.thumbnailfile |
56 | ? { thumbnailfile: options.playlistAttrs.thumbnailfile } | ||
57 | : {} | ||
29 | 58 | ||
30 | const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist) | 59 | return makeUploadRequest({ |
60 | method: 'POST', | ||
61 | url: options.url, | ||
62 | path, | ||
63 | token: options.token, | ||
64 | fields, | ||
65 | attaches, | ||
66 | statusCodeExpected: options.expectedStatus | ||
67 | }) | ||
68 | } | ||
31 | 69 | ||
32 | const length = parseInt(matches[1], 10) | 70 | function updateVideoPlaylist (options: { |
33 | const offset = parseInt(matches[2], 10) | 71 | url: string, |
34 | const range = `${offset}-${offset + length - 1}` | 72 | token: string, |
73 | playlistAttrs: VideoPlaylistUpdate, | ||
74 | expectedStatus: number | ||
75 | }) { | ||
76 | const path = '/api/v1/video-playlists/' | ||
35 | 77 | ||
36 | const res2 = await getSegment(`${baseUrlSegment}/${videoUUID}/${videoName}`, 206, `bytes=${range}`) | 78 | const fields = omit(options.playlistAttrs, 'thumbnailfile') |
37 | 79 | ||
38 | const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url) | 80 | const attaches = options.playlistAttrs.thumbnailfile |
81 | ? { thumbnailfile: options.playlistAttrs.thumbnailfile } | ||
82 | : {} | ||
39 | 83 | ||
40 | const sha256Server = resSha.body[ videoName ][range] | 84 | return makeUploadRequest({ |
41 | expect(sha256(res2.body)).to.equal(sha256Server) | 85 | method: 'PUT', |
86 | url: options.url, | ||
87 | path, | ||
88 | token: options.token, | ||
89 | fields, | ||
90 | attaches, | ||
91 | statusCodeExpected: options.expectedStatus | ||
92 | }) | ||
93 | } | ||
94 | |||
95 | function addVideoInPlaylist (options: { | ||
96 | url: string, | ||
97 | token: string, | ||
98 | playlistId: number | string, | ||
99 | elementAttrs: VideoPlaylistElementCreate | ||
100 | expectedStatus: number | ||
101 | }) { | ||
102 | const path = '/api/v1/video-playlists/' + options.playlistId + '/videos' | ||
103 | |||
104 | return makePostBodyRequest({ | ||
105 | url: options.url, | ||
106 | path, | ||
107 | token: options.token, | ||
108 | fields: options.elementAttrs, | ||
109 | statusCodeExpected: options.expectedStatus | ||
110 | }) | ||
111 | } | ||
112 | |||
113 | function updateVideoPlaylistElement (options: { | ||
114 | url: string, | ||
115 | token: string, | ||
116 | playlistId: number | string, | ||
117 | videoId: number | string, | ||
118 | elementAttrs: VideoPlaylistElementUpdate, | ||
119 | expectedStatus: number | ||
120 | }) { | ||
121 | const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId | ||
122 | |||
123 | return makePutBodyRequest({ | ||
124 | url: options.url, | ||
125 | path, | ||
126 | token: options.token, | ||
127 | fields: options.elementAttrs, | ||
128 | statusCodeExpected: options.expectedStatus | ||
129 | }) | ||
130 | } | ||
131 | |||
132 | function removeVideoFromPlaylist (options: { | ||
133 | url: string, | ||
134 | token: string, | ||
135 | playlistId: number | string, | ||
136 | videoId: number | string, | ||
137 | expectedStatus: number | ||
138 | }) { | ||
139 | const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId | ||
140 | |||
141 | return makeDeleteRequest({ | ||
142 | url: options.url, | ||
143 | path, | ||
144 | token: options.token, | ||
145 | statusCodeExpected: options.expectedStatus | ||
146 | }) | ||
147 | } | ||
148 | |||
149 | function reorderVideosPlaylist (options: { | ||
150 | url: string, | ||
151 | token: string, | ||
152 | playlistId: number | string, | ||
153 | elementAttrs: { | ||
154 | startPosition: number, | ||
155 | insertAfter: number, | ||
156 | reorderLength?: number | ||
157 | }, | ||
158 | expectedStatus: number | ||
159 | }) { | ||
160 | const path = '/api/v1/video-playlists/' + options.playlistId + '/videos' | ||
161 | |||
162 | return makePutBodyRequest({ | ||
163 | url: options.url, | ||
164 | path, | ||
165 | token: options.token, | ||
166 | fields: options.elementAttrs, | ||
167 | statusCodeExpected: options.expectedStatus | ||
168 | }) | ||
42 | } | 169 | } |
43 | 170 | ||
44 | // --------------------------------------------------------------------------- | 171 | // --------------------------------------------------------------------------- |
45 | 172 | ||
46 | export { | 173 | export { |
47 | getPlaylist, | 174 | getVideoPlaylistsList, |
48 | getSegment, | 175 | getVideoPlaylist, |
49 | getSegmentSha256, | 176 | |
50 | checkSegmentHash | 177 | createVideoPlaylist, |
178 | updateVideoPlaylist, | ||
179 | deleteVideoPlaylist, | ||
180 | |||
181 | addVideoInPlaylist, | ||
182 | removeVideoFromPlaylist, | ||
183 | |||
184 | reorderVideosPlaylist | ||
51 | } | 185 | } |
diff --git a/shared/utils/videos/video-streaming-playlists.ts b/shared/utils/videos/video-streaming-playlists.ts new file mode 100644 index 000000000..eb25011cb --- /dev/null +++ b/shared/utils/videos/video-streaming-playlists.ts | |||
@@ -0,0 +1,51 @@ | |||
1 | import { makeRawRequest } from '../requests/requests' | ||
2 | import { sha256 } from '../../../server/helpers/core-utils' | ||
3 | import { VideoStreamingPlaylist } from '../../models/videos/video-streaming-playlist.model' | ||
4 | import { expect } from 'chai' | ||
5 | |||
6 | function getPlaylist (url: string, statusCodeExpected = 200) { | ||
7 | return makeRawRequest(url, statusCodeExpected) | ||
8 | } | ||
9 | |||
10 | function getSegment (url: string, statusCodeExpected = 200, range?: string) { | ||
11 | return makeRawRequest(url, statusCodeExpected, range) | ||
12 | } | ||
13 | |||
14 | function getSegmentSha256 (url: string, statusCodeExpected = 200) { | ||
15 | return makeRawRequest(url, statusCodeExpected) | ||
16 | } | ||
17 | |||
18 | async function checkSegmentHash ( | ||
19 | baseUrlPlaylist: string, | ||
20 | baseUrlSegment: string, | ||
21 | videoUUID: string, | ||
22 | resolution: number, | ||
23 | hlsPlaylist: VideoStreamingPlaylist | ||
24 | ) { | ||
25 | const res = await getPlaylist(`${baseUrlPlaylist}/${videoUUID}/${resolution}.m3u8`) | ||
26 | const playlist = res.text | ||
27 | |||
28 | const videoName = `${videoUUID}-${resolution}-fragmented.mp4` | ||
29 | |||
30 | const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist) | ||
31 | |||
32 | const length = parseInt(matches[1], 10) | ||
33 | const offset = parseInt(matches[2], 10) | ||
34 | const range = `${offset}-${offset + length - 1}` | ||
35 | |||
36 | const res2 = await getSegment(`${baseUrlSegment}/${videoUUID}/${videoName}`, 206, `bytes=${range}`) | ||
37 | |||
38 | const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url) | ||
39 | |||
40 | const sha256Server = resSha.body[ videoName ][range] | ||
41 | expect(sha256(res2.body)).to.equal(sha256Server) | ||
42 | } | ||
43 | |||
44 | // --------------------------------------------------------------------------- | ||
45 | |||
46 | export { | ||
47 | getPlaylist, | ||
48 | getSegment, | ||
49 | getSegmentSha256, | ||
50 | checkSegmentHash | ||
51 | } | ||
diff --git a/shared/utils/videos/videos.ts b/shared/utils/videos/videos.ts index b3d24bc53..2c09f0086 100644 --- a/shared/utils/videos/videos.ts +++ b/shared/utils/videos/videos.ts | |||
@@ -223,6 +223,28 @@ function getVideoChannelVideos ( | |||
223 | }) | 223 | }) |
224 | } | 224 | } |
225 | 225 | ||
226 | function getPlaylistVideos ( | ||
227 | url: string, | ||
228 | accessToken: string, | ||
229 | playlistId: number | string, | ||
230 | start: number, | ||
231 | count: number, | ||
232 | query: { nsfw?: boolean } = {} | ||
233 | ) { | ||
234 | const path = '/api/v1/video-playlists/' + playlistId + '/videos' | ||
235 | |||
236 | return makeGetRequest({ | ||
237 | url, | ||
238 | path, | ||
239 | query: immutableAssign(query, { | ||
240 | start, | ||
241 | count | ||
242 | }), | ||
243 | token: accessToken, | ||
244 | statusCodeExpected: 200 | ||
245 | }) | ||
246 | } | ||
247 | |||
226 | function getVideosListPagination (url: string, start: number, count: number, sort?: string) { | 248 | function getVideosListPagination (url: string, start: number, count: number, sort?: string) { |
227 | const path = '/api/v1/videos' | 249 | const path = '/api/v1/videos' |
228 | 250 | ||
@@ -601,5 +623,6 @@ export { | |||
601 | parseTorrentVideo, | 623 | parseTorrentVideo, |
602 | getLocalVideos, | 624 | getLocalVideos, |
603 | completeVideoCheck, | 625 | completeVideoCheck, |
604 | checkVideoFilesWereRemoved | 626 | checkVideoFilesWereRemoved, |
627 | getPlaylistVideos | ||
605 | } | 628 | } |