aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--server.ts2
-rw-r--r--server/controllers/activitypub/client.ts59
-rw-r--r--server/controllers/activitypub/outbox.ts2
-rw-r--r--server/controllers/api/accounts.ts57
-rw-r--r--server/controllers/api/index.ts2
-rw-r--r--server/controllers/api/video-channel.ts27
-rw-r--r--server/controllers/api/video-playlist.ts415
-rw-r--r--server/controllers/api/videos/blacklist.ts4
-rw-r--r--server/controllers/services.ts4
-rw-r--r--server/helpers/activitypub.ts9
-rw-r--r--server/helpers/custom-validators/activitypub/activity.ts3
-rw-r--r--server/helpers/custom-validators/activitypub/playlist.ts25
-rw-r--r--server/helpers/custom-validators/video-playlists.ts44
-rw-r--r--server/helpers/custom-validators/videos.ts2
-rw-r--r--server/initializers/constants.ts23
-rw-r--r--server/initializers/database.ts6
-rw-r--r--server/lib/activitypub/actor.ts13
-rw-r--r--server/lib/activitypub/cache-file.ts2
-rw-r--r--server/lib/activitypub/crawl.ts2
-rw-r--r--server/lib/activitypub/playlist.ts162
-rw-r--r--server/lib/activitypub/process/process-create.ts19
-rw-r--r--server/lib/activitypub/process/process-update.ts15
-rw-r--r--server/lib/activitypub/send/send-create.ts23
-rw-r--r--server/lib/activitypub/send/send-delete.ts21
-rw-r--r--server/lib/activitypub/send/send-update.ts30
-rw-r--r--server/lib/activitypub/url.ts12
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-fetcher.ts11
-rw-r--r--server/middlewares/validators/account.ts4
-rw-r--r--server/middlewares/validators/sort.ts5
-rw-r--r--server/middlewares/validators/videos/video-channels.ts14
-rw-r--r--server/middlewares/validators/videos/video-imports.ts4
-rw-r--r--server/middlewares/validators/videos/video-playlists.ts302
-rw-r--r--server/middlewares/validators/videos/videos.ts8
-rw-r--r--server/models/account/account.ts60
-rw-r--r--server/models/activitypub/actor-follow.ts2
-rw-r--r--server/models/activitypub/actor.ts5
-rw-r--r--server/models/utils.ts21
-rw-r--r--server/models/video/video-channel.ts78
-rw-r--r--server/models/video/video-format-utils.ts36
-rw-r--r--server/models/video/video-playlist-element.ts231
-rw-r--r--server/models/video/video-playlist.ts381
-rw-r--r--server/models/video/video.ts127
-rw-r--r--server/tests/api/check-params/video-playlists.ts117
-rw-r--r--server/tests/api/videos/video-playlists.ts161
-rw-r--r--shared/models/activitypub/activity.ts5
-rw-r--r--shared/models/activitypub/activitypub-actor.ts1
-rw-r--r--shared/models/activitypub/objects/playlist-element-object.ts10
-rw-r--r--shared/models/activitypub/objects/playlist-object.ts23
-rw-r--r--shared/models/actors/account.model.ts11
-rw-r--r--shared/models/overviews/videos-overview.ts4
-rw-r--r--shared/models/users/user-right.enum.ts4
-rw-r--r--shared/models/users/user-role.ts1
-rw-r--r--shared/models/videos/channel/video-channel.model.ts12
-rw-r--r--shared/models/videos/playlist/video-playlist-create.model.ts11
-rw-r--r--shared/models/videos/playlist/video-playlist-element-create.model.ts4
-rw-r--r--shared/models/videos/playlist/video-playlist-element-update.model.ts4
-rw-r--r--shared/models/videos/playlist/video-playlist-privacy.model.ts5
-rw-r--r--shared/models/videos/playlist/video-playlist-update.model.ts10
-rw-r--r--shared/models/videos/playlist/video-playlist.model.ts23
-rw-r--r--shared/models/videos/video.model.ts32
-rw-r--r--shared/utils/videos/video-playlists.ts198
-rw-r--r--shared/utils/videos/video-streaming-playlists.ts51
-rw-r--r--shared/utils/videos/videos.ts25
63 files changed, 2758 insertions, 226 deletions
diff --git a/server.ts b/server.ts
index c450d5b6e..9fe741175 100644
--- a/server.ts
+++ b/server.ts
@@ -53,7 +53,7 @@ if (errorMessage !== null) {
53app.set('trust proxy', CONFIG.TRUST_PROXY) 53app.set('trust proxy', CONFIG.TRUST_PROXY)
54 54
55// Security middleware 55// Security middleware
56import { baseCSP } from './server/middlewares' 56import { baseCSP } from './server/middlewares/csp'
57 57
58if (CONFIG.CSP.ENABLED) { 58if (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'
17import { getAccountVideoRateValidator, videoCommentGetValidator, videosGetValidator } from '../../middlewares/validators' 17import { getAccountVideoRateValidator, videoCommentGetValidator } from '../../middlewares/validators'
18import { AccountModel } from '../../models/account/account' 18import { AccountModel } from '../../models/account/account'
19import { ActorModel } from '../../models/activitypub/actor' 19import { ActorModel } from '../../models/activitypub/actor'
20import { ActorFollowModel } from '../../models/activitypub/actor-follow' 20import { ActorFollowModel } from '../../models/activitypub/actor-follow'
@@ -37,6 +37,10 @@ import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator }
37import { getServerActor } from '../../helpers/utils' 37import { getServerActor } from '../../helpers/utils'
38import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 38import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
39import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike' 39import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike'
40import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists'
41import { VideoPlaylistModel } from '../../models/video/video-playlist'
42import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
43import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
40 44
41const activityPubClientRouter = express.Router() 45const 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)
59activityPubClientRouter.get('/accounts?/:name/playlists',
60 executeIfActivityPub(asyncMiddleware(localAccountValidator)),
61 executeIfActivityPub(asyncMiddleware(accountPlaylistsController))
62)
55activityPubClientRouter.get('/accounts?/:name/likes/:videoId', 63activityPubClientRouter.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
132activityPubClientRouter.get('/video-playlists/:playlistId',
133 executeIfActivityPub(asyncMiddleware(videoPlaylistsGetValidator)),
134 executeIfActivityPub(asyncMiddleware(videoPlaylistController))
135)
136activityPubClientRouter.get('/video-playlists/:playlistId/:videoId',
137 executeIfActivityPub(asyncMiddleware(videoPlaylistElementAPGetValidator)),
138 executeIfActivityPub(asyncMiddleware(videoPlaylistElementController))
139)
140
124// --------------------------------------------------------------------------- 141// ---------------------------------------------------------------------------
125 142
126export { 143export {
@@ -129,26 +146,33 @@ export {
129 146
130// --------------------------------------------------------------------------- 147// ---------------------------------------------------------------------------
131 148
132function accountController (req: express.Request, res: express.Response, next: express.NextFunction) { 149function 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
138async function accountFollowersController (req: express.Request, res: express.Response, next: express.NextFunction) { 155async 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
145async function accountFollowingController (req: express.Request, res: express.Response, next: express.NextFunction) { 162async 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
169async 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
152function getAccountVideoRate (rateType: VideoRateType) { 176function 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
320async 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
330async 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
298async function actorFollowing (req: express.Request, actor: ActorModel) { 339async function actorFollowing (req: express.Request, actor: ActorModel) {
@@ -305,7 +346,15 @@ async function actorFollowing (req: express.Request, actor: ActorModel) {
305 346
306async function actorFollowers (req: express.Request, actor: ActorModel) { 347async 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
355async 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
35async function outboxController (req: express.Request, res: express.Response, next: express.NextFunction) { 35async 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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { getFormattedObjects } from '../../helpers/utils' 2import { getFormattedObjects, getServerActor } from '../../helpers/utils'
3import { 3import {
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'
12import { accountsNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators' 12import { accountNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators'
13import { AccountModel } from '../../models/account/account' 13import { AccountModel } from '../../models/account/account'
14import { VideoModel } from '../../models/video/video' 14import { VideoModel } from '../../models/video/video'
15import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' 15import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
16import { VideoChannelModel } from '../../models/video/video-channel' 16import { VideoChannelModel } from '../../models/video/video-channel'
17import { JobQueue } from '../../lib/job-queue' 17import { JobQueue } from '../../lib/job-queue'
18import { logger } from '../../helpers/logger' 18import { logger } from '../../helpers/logger'
19import { VideoPlaylistModel } from '../../models/video/video-playlist'
20import { UserModel } from '../../models/account/user'
19 21
20const accountsRouter = express.Router() 22const accountsRouter = express.Router()
21 23
@@ -28,12 +30,12 @@ accountsRouter.get('/',
28) 30)
29 31
30accountsRouter.get('/:accountName', 32accountsRouter.get('/:accountName',
31 asyncMiddleware(accountsNameWithHostGetValidator), 33 asyncMiddleware(accountNameWithHostGetValidator),
32 getAccount 34 getAccount
33) 35)
34 36
35accountsRouter.get('/:accountName/videos', 37accountsRouter.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
46accountsRouter.get('/:accountName/video-channels', 48accountsRouter.get('/:accountName/video-channels',
47 asyncMiddleware(listVideoAccountChannelsValidator), 49 asyncMiddleware(accountNameWithHostGetValidator),
48 asyncMiddleware(listVideoAccountChannels) 50 asyncMiddleware(listAccountChannels)
51)
52
53accountsRouter.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
59function getAccount (req: express.Request, res: express.Response, next: express.NextFunction) { 71function 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
70async function listAccounts (req: express.Request, res: express.Response, next: express.NextFunction) { 82async 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
76async function listVideoAccountChannels (req: express.Request, res: express.Response, next: express.NextFunction) { 88async 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
82async function listAccountVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 94async 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
115async 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'
11import * as cors from 'cors' 11import * as cors from 'cors'
12import { searchRouter } from './search' 12import { searchRouter } from './search'
13import { overviewsRouter } from './overviews' 13import { overviewsRouter } from './overviews'
14import { videoPlaylistRouter } from './video-playlist'
14 15
15const apiRouter = express.Router() 16const apiRouter = express.Router()
16 17
@@ -26,6 +27,7 @@ apiRouter.use('/config', configRouter)
26apiRouter.use('/users', usersRouter) 27apiRouter.use('/users', usersRouter)
27apiRouter.use('/accounts', accountsRouter) 28apiRouter.use('/accounts', accountsRouter)
28apiRouter.use('/video-channels', videoChannelRouter) 29apiRouter.use('/video-channels', videoChannelRouter)
30apiRouter.use('/video-playlists', videoPlaylistRouter)
29apiRouter.use('/videos', videosRouter) 31apiRouter.use('/videos', videosRouter)
30apiRouter.use('/jobs', jobsRouter) 32apiRouter.use('/jobs', jobsRouter)
31apiRouter.use('/search', searchRouter) 33apiRouter.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'
17import { VideoChannelModel } from '../../models/video/video-channel' 18import { VideoChannelModel } from '../../models/video/video-channel'
18import { videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators' 19import { videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators'
@@ -31,6 +32,7 @@ import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '..
31import { resetSequelizeInstance } from '../../helpers/database-utils' 32import { resetSequelizeInstance } from '../../helpers/database-utils'
32import { UserModel } from '../../models/account/user' 33import { UserModel } from '../../models/account/user'
33import { JobQueue } from '../../lib/job-queue' 34import { JobQueue } from '../../lib/job-queue'
35import { VideoPlaylistModel } from '../../models/video/video-playlist'
34 36
35const auditLogger = auditLoggerFactory('channels') 37const auditLogger = auditLoggerFactory('channels')
36const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR }) 38const 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
82videoChannelRouter.get('/:nameWithHost/video-playlists',
83 asyncMiddleware(videoChannelsNameWithHostValidator),
84 paginationValidator,
85 videoPlaylistsSortValidator,
86 setDefaultSort,
87 setDefaultPagination,
88 asyncMiddleware(listVideoChannelPlaylists)
89)
90
80videoChannelRouter.get('/:nameWithHost/videos', 91videoChannelRouter.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
220async 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
209async function listVideoChannelVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 234async 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 @@
1import * as express from 'express'
2import { getFormattedObjects, getServerActor } from '../../helpers/utils'
3import {
4 asyncMiddleware,
5 asyncRetryTransactionMiddleware,
6 authenticate,
7 commonVideosFiltersValidator,
8 paginationValidator,
9 setDefaultPagination,
10 setDefaultSort
11} from '../../middlewares'
12import { VideoChannelModel } from '../../models/video/video-channel'
13import { videoPlaylistsSortValidator } from '../../middlewares/validators'
14import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
15import { CONFIG, MIMETYPES, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
16import { logger } from '../../helpers/logger'
17import { resetSequelizeInstance } from '../../helpers/database-utils'
18import { VideoPlaylistModel } from '../../models/video/video-playlist'
19import {
20 videoPlaylistsAddValidator,
21 videoPlaylistsAddVideoValidator,
22 videoPlaylistsDeleteValidator,
23 videoPlaylistsGetValidator,
24 videoPlaylistsReorderVideosValidator,
25 videoPlaylistsUpdateOrRemoveVideoValidator,
26 videoPlaylistsUpdateValidator
27} from '../../middlewares/validators/videos/video-playlists'
28import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
29import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
30import { processImage } from '../../helpers/image-utils'
31import { join } from 'path'
32import { UserModel } from '../../models/account/user'
33import {
34 getVideoPlaylistActivityPubUrl,
35 getVideoPlaylistElementActivityPubUrl,
36 sendCreateVideoPlaylist,
37 sendDeleteVideoPlaylist,
38 sendUpdateVideoPlaylist
39} from '../../lib/activitypub'
40import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model'
41import { VideoModel } from '../../models/video/video'
42import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
43import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
44import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
45import { copy, pathExists } from 'fs-extra'
46
47const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
48
49const videoPlaylistRouter = express.Router()
50
51videoPlaylistRouter.get('/',
52 paginationValidator,
53 videoPlaylistsSortValidator,
54 setDefaultSort,
55 setDefaultPagination,
56 asyncMiddleware(listVideoPlaylists)
57)
58
59videoPlaylistRouter.get('/:playlistId',
60 asyncMiddleware(videoPlaylistsGetValidator),
61 getVideoPlaylist
62)
63
64videoPlaylistRouter.post('/',
65 authenticate,
66 reqThumbnailFile,
67 asyncMiddleware(videoPlaylistsAddValidator),
68 asyncRetryTransactionMiddleware(addVideoPlaylist)
69)
70
71videoPlaylistRouter.put('/:playlistId',
72 authenticate,
73 reqThumbnailFile,
74 asyncMiddleware(videoPlaylistsUpdateValidator),
75 asyncRetryTransactionMiddleware(updateVideoPlaylist)
76)
77
78videoPlaylistRouter.delete('/:playlistId',
79 authenticate,
80 asyncMiddleware(videoPlaylistsDeleteValidator),
81 asyncRetryTransactionMiddleware(removeVideoPlaylist)
82)
83
84videoPlaylistRouter.get('/:playlistId/videos',
85 asyncMiddleware(videoPlaylistsGetValidator),
86 paginationValidator,
87 setDefaultPagination,
88 commonVideosFiltersValidator,
89 asyncMiddleware(getVideoPlaylistVideos)
90)
91
92videoPlaylistRouter.post('/:playlistId/videos',
93 authenticate,
94 asyncMiddleware(videoPlaylistsAddVideoValidator),
95 asyncRetryTransactionMiddleware(addVideoInPlaylist)
96)
97
98videoPlaylistRouter.put('/:playlistId/videos',
99 authenticate,
100 asyncMiddleware(videoPlaylistsReorderVideosValidator),
101 asyncRetryTransactionMiddleware(reorderVideosPlaylist)
102)
103
104videoPlaylistRouter.put('/:playlistId/videos/:videoId',
105 authenticate,
106 asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
107 asyncRetryTransactionMiddleware(updateVideoPlaylistElement)
108)
109
110videoPlaylistRouter.delete('/:playlistId/videos/:videoId',
111 authenticate,
112 asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
113 asyncRetryTransactionMiddleware(removeVideoFromPlaylist)
114)
115
116// ---------------------------------------------------------------------------
117
118export {
119 videoPlaylistRouter
120}
121
122// ---------------------------------------------------------------------------
123
124async 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
136function getVideoPlaylist (req: express.Request, res: express.Response) {
137 const videoPlaylist = res.locals.videoPlaylist as VideoPlaylistModel
138
139 return res.json(videoPlaylist.toFormattedJSON())
140}
141
142async 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
186async 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
253async 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
267async 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
308async 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
329async 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
348async 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
392async 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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { VideoBlacklist, UserRight, VideoBlacklistCreate } from '../../../../shared' 2import { UserRight, VideoBlacklist, VideoBlacklistCreate } from '../../../../shared'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { getFormattedObjects } from '../../../helpers/utils' 4import { getFormattedObjects } from '../../../helpers/utils'
5import { 5import {
@@ -18,7 +18,7 @@ import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
18import { sequelizeTypescript } from '../../../initializers' 18import { sequelizeTypescript } from '../../../initializers'
19import { Notifier } from '../../../lib/notifier' 19import { Notifier } from '../../../lib/notifier'
20import { VideoModel } from '../../../models/video/video' 20import { VideoModel } from '../../../models/video/video'
21import { sendCreateVideo, sendDeleteVideo, sendUpdateVideo } from '../../../lib/activitypub/send' 21import { sendDeleteVideo } from '../../../lib/activitypub/send'
22import { federateVideoIfNeeded } from '../../../lib/activitypub' 22import { federateVideoIfNeeded } from '../../../lib/activitypub'
23 23
24const blacklistRouter = express.Router() 24const 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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { CONFIG, EMBED_SIZE, PREVIEWS_SIZE } from '../initializers' 2import { CONFIG, EMBED_SIZE, PREVIEWS_SIZE } from '../initializers'
3import { asyncMiddleware, oembedValidator } from '../middlewares' 3import { asyncMiddleware, oembedValidator } from '../middlewares'
4import { accountsNameWithHostGetValidator } from '../middlewares/validators' 4import { accountNameWithHostGetValidator } from '../middlewares/validators'
5import { VideoModel } from '../models/video/video' 5import { VideoModel } from '../models/video/video'
6 6
7const servicesRouter = express.Router() 7const servicesRouter = express.Router()
@@ -11,7 +11,7 @@ servicesRouter.use('/oembed',
11 generateOEmbed 11 generateOEmbed
12) 12)
13servicesRouter.use('/redirect/accounts/:accountName', 13servicesRouter.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'
9import { exists } from '../misc' 9import { exists } from '../misc'
10import { isCacheFileObjectValid } from './cache-file' 10import { isCacheFileObjectValid } from './cache-file'
11import { isFlagActivityValid } from './flag' 11import { isFlagActivityValid } from './flag'
12import { isPlaylistObjectValid } from './playlist'
12 13
13function isRootActivityValid (activity: any) { 14function 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 @@
1import { exists } from '../misc'
2import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
3import * as validator from 'validator'
4import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object'
5import { isActivityPubUrlValid } from './misc'
6
7function isPlaylistObjectValid (object: PlaylistObject) {
8 return exists(object) &&
9 object.type === 'Playlist' &&
10 validator.isInt(object.totalItems + '')
11}
12
13function isPlaylistElementObjectValid (object: PlaylistElementObject) {
14 return exists(object) &&
15 object.type === 'PlaylistElement' &&
16 validator.isInt(object.position + '') &&
17 isActivityPubUrlValid(object.url)
18}
19
20// ---------------------------------------------------------------------------
21
22export {
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 @@
1import { exists } from './misc'
2import * as validator from 'validator'
3import { CONSTRAINTS_FIELDS, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers'
4import * as express from 'express'
5import { VideoPlaylistModel } from '../../models/video/video-playlist'
6import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
7
8const PLAYLISTS_CONSTRAINT_FIELDS = CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS
9
10function isVideoPlaylistNameValid (value: any) {
11 return exists(value) && validator.isLength(value, PLAYLISTS_CONSTRAINT_FIELDS.NAME)
12}
13
14function isVideoPlaylistDescriptionValid (value: any) {
15 return value === null || (exists(value) && validator.isLength(value, PLAYLISTS_CONSTRAINT_FIELDS.DESCRIPTION))
16}
17
18function isVideoPlaylistPrivacyValid (value: number) {
19 return validator.isInt(value + '') && VIDEO_PLAYLIST_PRIVACIES[ value ] !== undefined
20}
21
22async 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
39export {
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
168async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') { 168async 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'
10import { invert } from 'lodash' 10import { invert } from 'lodash'
11import { CronRepeatOptions, EveryRepeatOptions } from 'bull' 11import { CronRepeatOptions, EveryRepeatOptions } from 'bull'
12import * as bytes from 'bytes' 12import * as bytes from 'bytes'
13import { 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
15let config: IConfig = require('config') 16let 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
58const OAUTH_LIFETIME = { 61const 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
519const VIDEO_PLAYLIST_PRIVACIES = {
520 [VideoPlaylistPrivacy.PUBLIC]: 'Public',
521 [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
522 [VideoPlaylistPrivacy.PRIVATE]: 'Private'
523}
524
505const MIMETYPES = { 525const 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'
34import { UserNotificationModel } from '../models/account/user-notification' 34import { UserNotificationModel } from '../models/account/user-notification'
35import { UserNotificationSettingModel } from '../models/account/user-notification-setting' 35import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
36import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 36import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
37import { VideoPlaylistModel } from '../models/video/video-playlist'
38import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
37 39
38require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 40require('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 @@
1import { ActivityPlaylistUrlObject, ActivityVideoUrlObject, CacheFileObject } from '../../../shared/index' 1import { CacheFileObject } from '../../../shared/index'
2import { VideoModel } from '../../models/video/video' 2import { VideoModel } from '../../models/video/video'
3import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 3import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
4import { Transaction } from 'sequelize' 4import { 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'
4import * as Bluebird from 'bluebird' 4import * as Bluebird from 'bluebird'
5import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub' 5import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
6 6
7async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) { 7async 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 @@
1import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
2import { crawlCollectionPage } from './crawl'
3import { ACTIVITY_PUB, CONFIG, CRAWL_REQUEST_CONCURRENCY, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
4import { AccountModel } from '../../models/account/account'
5import { isArray } from '../../helpers/custom-validators/misc'
6import { getOrCreateActorAndServerAndModel } from './actor'
7import { logger } from '../../helpers/logger'
8import { VideoPlaylistModel } from '../../models/video/video-playlist'
9import { doRequest, downloadImage } from '../../helpers/requests'
10import { checkUrlsSameHost } from '../../helpers/activitypub'
11import * as Bluebird from 'bluebird'
12import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
13import { getOrCreateVideoAndAccountAndChannel } from './videos'
14import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '../../helpers/custom-validators/activitypub/playlist'
15import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
16import { VideoModel } from '../../models/video/video'
17import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
18import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
19import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
20
21function 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
35function 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
46async 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
74async 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
110export {
111 createAccountPlaylists,
112 playlistObjectToDBAttributes,
113 playlistElementObjectToDBAttributes,
114 createOrUpdateVideoPlaylist
115}
116
117// ---------------------------------------------------------------------------
118
119async 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
158function 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'
12import { processViewActivity } from './process-view' 12import { processViewActivity } from './process-view'
13import { processDislikeActivity } from './process-dislike' 13import { processDislikeActivity } from './process-dislike'
14import { processFlagActivity } from './process-flag' 14import { processFlagActivity } from './process-flag'
15import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
16import { createOrUpdateVideoPlaylist } from '../playlist'
15 17
16async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) { 18async 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
66async function processCacheFile (activity: ActivityCreate, byActor: ActorModel) { 72async 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
108async 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
12import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file' 12import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
13import { createOrUpdateCacheFile } from '../cache-file' 13import { createOrUpdateCacheFile } from '../cache-file'
14import { forwardVideoRelatedActivity } from '../send/utils' 14import { forwardVideoRelatedActivity } from '../send/utils'
15import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
16import { createOrUpdateVideoPlaylist } from '../playlist'
15 17
16async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorModel) { 18async 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
145async 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
8import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience' 8import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience'
9import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
10import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' 10import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
11import { VideoPlaylistModel } from '../../../models/video/video-playlist'
12import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
13import { getServerActor } from '../../../helpers/utils'
11 14
12async function sendCreateVideo (video: VideoModel, t: Transaction) { 15async 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
40async 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
37async function sendCreateVideoComment (comment: VideoCommentModel, t: Transaction) { 59async 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'
8import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' 8import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
9import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience' 9import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
10import { logger } from '../../../helpers/logger' 10import { logger } from '../../../helpers/logger'
11import { VideoPlaylistModel } from '../../../models/video/video-playlist'
12import { getServerActor } from '../../../helpers/utils'
11 13
12async function sendDeleteVideo (video: VideoModel, transaction: Transaction) { 14async 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
69async 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
69export { 87export {
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'
12import { logger } from '../../../helpers/logger' 12import { logger } from '../../../helpers/logger'
13import { VideoCaptionModel } from '../../../models/video/video-caption' 13import { VideoCaptionModel } from '../../../models/video/video-caption'
14import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' 14import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
15import { VideoPlaylistModel } from '../../../models/video/video-playlist'
16import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
17import { getServerActor } from '../../../helpers/utils'
15 18
16async function sendUpdateVideo (video: VideoModel, t: Transaction, overrodeByActor?: ActorModel) { 19async 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
81async 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
78export { 105export {
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'
7import { VideoFileModel } from '../../models/video/video-file' 7import { VideoFileModel } from '../../models/video/video-file'
8import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' 8import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
9import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist' 9import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
10import { VideoPlaylistModel } from '../../models/video/video-playlist'
11import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
10 12
11function getVideoActivityPubUrl (video: VideoModel) { 13function 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
17function getVideoPlaylistActivityPubUrl (videoPlaylist: VideoPlaylistModel) {
18 return CONFIG.WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid
19}
20
21function getVideoPlaylistElementActivityPubUrl (videoPlaylist: VideoPlaylistModel, video: VideoModel) {
22 return CONFIG.WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid + '/' + video.uuid
23}
24
15function getVideoCacheFileActivityPubUrl (videoFile: VideoFileModel) { 25function 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
99export { 109export {
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'
5import { crawlCollectionPage } from '../../activitypub/crawl' 5import { crawlCollectionPage } from '../../activitypub/crawl'
6import { VideoModel } from '../../../models/video/video' 6import { VideoModel } from '../../../models/video/video'
7import { addVideoShares, createRates } from '../../activitypub' 7import { addVideoShares, createRates } from '../../activitypub'
8import { createAccountPlaylists } from '../../activitypub/playlist'
9import { AccountModel } from '../../../models/account/account'
8 10
9type FetchType = 'activity' | 'video-likes' | 'video-dislikes' | 'video-shares' | 'video-comments' 11type FetchType = 'activity' | 'video-likes' | 'video-dislikes' | 'video-shares' | 'video-comments' | 'account-playlists'
10 12
11export type ActivitypubHttpFetcherPayload = { 13export 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
17async function processActivityPubHttpFetcher (job: Bull.Job) { 20async 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
20const accountsNameWithHostGetValidator = [ 20const 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
35export { 35export {
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
19const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST) 19const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
20const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST) 20const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
21const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS) 21const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
22const SORTABLE_VIDEO_PLAYLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
22 23
23const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) 24const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
24const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) 25const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@@ -37,6 +38,7 @@ const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COL
37const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS) 38const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS)
38const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS) 39const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
39const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS) 40const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
41const 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'
16import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor' 16import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor'
17import { ActorModel } from '../../../models/activitypub/actor' 17import { ActorModel } from '../../../models/activitypub/actor'
18 18
19const 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
32const videoChannelsAddValidator = [ 19const 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
129export { 116export {
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'
3import { isIdValid } from '../../../helpers/custom-validators/misc' 3import { isIdValid } from '../../../helpers/custom-validators/misc'
4import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
5import { areValidationErrors } from '../utils' 5import { areValidationErrors } from '../utils'
6import { getCommonVideoAttributes } from './videos' 6import { getCommonVideoEditAttributes } from './videos'
7import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' 7import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
8import { cleanUpReqFiles } from '../../../helpers/express-utils' 8import { cleanUpReqFiles } from '../../../helpers/express-utils'
9import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' 9import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos'
10import { CONFIG } from '../../../initializers/constants' 10import { CONFIG } from '../../../initializers/constants'
11import { CONSTRAINTS_FIELDS } from '../../../initializers' 11import { CONSTRAINTS_FIELDS } from '../../../initializers'
12 12
13const videoImportAddValidator = getCommonVideoAttributes().concat([ 13const 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 @@
1import * as express from 'express'
2import { body, param, ValidationChain } from 'express-validator/check'
3import { UserRight, VideoPrivacy } from '../../../../shared'
4import { logger } from '../../../helpers/logger'
5import { UserModel } from '../../../models/account/user'
6import { areValidationErrors } from '../utils'
7import { isVideoExist, isVideoImage } from '../../../helpers/custom-validators/videos'
8import { CONSTRAINTS_FIELDS } from '../../../initializers'
9import { isIdOrUUIDValid, toValueOrNull } from '../../../helpers/custom-validators/misc'
10import {
11 isVideoPlaylistDescriptionValid,
12 isVideoPlaylistExist,
13 isVideoPlaylistNameValid,
14 isVideoPlaylistPrivacyValid
15} from '../../../helpers/custom-validators/video-playlists'
16import { VideoPlaylistModel } from '../../../models/video/video-playlist'
17import { cleanUpReqFiles } from '../../../helpers/express-utils'
18import { isVideoChannelIdExist } from '../../../helpers/custom-validators/video-channels'
19import { VideoPlaylistElementModel } from '../../../models/video/video-playlist-element'
20import { VideoModel } from '../../../models/video/video'
21import { authenticatePromiseIfNeeded } from '../../oauth'
22import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
23
24const 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
36const 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
56const 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
74const 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
106const 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
146const 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
185const 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
215const 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
242export {
243 videoPlaylistsAddValidator,
244 videoPlaylistsUpdateValidator,
245 videoPlaylistsDeleteValidator,
246 videoPlaylistsGetValidator,
247
248 videoPlaylistsAddVideoValidator,
249 videoPlaylistsUpdateOrRemoveVideoValidator,
250 videoPlaylistsReorderVideosValidator,
251
252 videoPlaylistElementAPGetValidator
253}
254
255// ---------------------------------------------------------------------------
256
257function 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
281function 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'
46import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' 46import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
47import { getServerActor } from '../../../helpers/utils' 47import { getServerActor } from '../../../helpers/utils'
48 48
49const videosAddValidator = getCommonVideoAttributes().concat([ 49const 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
97const videosUpdateValidator = getCommonVideoAttributes().concat([ 97const 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
291function getCommonVideoAttributes () { 291function 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'
17import { Account } from '../../../shared/models/actors' 17import { Account, AccountSummary } from '../../../shared/models/actors'
18import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' 18import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
19import { sendDeleteActor } from '../../lib/activitypub/send' 19import { sendDeleteActor } from '../../lib/activitypub/send'
20import { ActorModel } from '../activitypub/actor' 20import { ActorModel } from '../activitypub/actor'
@@ -25,6 +25,13 @@ import { VideoChannelModel } from '../video/video-channel'
25import { VideoCommentModel } from '../video/video-comment' 25import { VideoCommentModel } from '../video/video-comment'
26import { UserModel } from './user' 26import { UserModel } from './user'
27import { CONFIG } from '../../initializers' 27import { CONFIG } from '../../initializers'
28import { AvatarModel } from '../avatar/avatar'
29import { WhereOptions } from 'sequelize'
30import { VideoPlaylistModel } from '../video/video-playlist'
31
32export 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 @@
1import { Sequelize } from 'sequelize-typescript' 1import { Sequelize } from 'sequelize-typescript'
2import * as validator from 'validator'
2 3
3type SortType = { sortModel: any, sortValue: string } 4type 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
85function 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 95function 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'
19import { ActivityPubActor } from '../../../shared/models/activitypub' 19import { ActivityPubActor } from '../../../shared/models/activitypub'
20import { VideoChannel } from '../../../shared/models/videos' 20import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
21import { 21import {
22 isVideoChannelDescriptionValid, 22 isVideoChannelDescriptionValid,
23 isVideoChannelNameValid, 23 isVideoChannelNameValid,
24 isVideoChannelSupportValid 24 isVideoChannelSupportValid
25} from '../../helpers/custom-validators/video-channels' 25} from '../../helpers/custom-validators/video-channels'
26import { sendDeleteActor } from '../../lib/activitypub/send' 26import { sendDeleteActor } from '../../lib/activitypub/send'
27import { AccountModel } from '../account/account' 27import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account'
28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' 28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
29import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 29import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
30import { VideoModel } from './video' 30import { VideoModel } from './video'
31import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' 31import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
32import { ServerModel } from '../server/server' 32import { ServerModel } from '../server/server'
33import { DefineIndexesOptions } from 'sequelize' 33import { DefineIndexesOptions } from 'sequelize'
34import { AvatarModel } from '../avatar/avatar'
35import { VideoPlaylistModel } from './video-playlist'
34 36
35// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 37// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
36const indexes: DefineIndexesOptions[] = [ 38const indexes: DefineIndexesOptions[] = [
@@ -44,11 +46,12 @@ const indexes: DefineIndexesOptions[] = [
44 } 46 }
45] 47]
46 48
47enum ScopeNames { 49export enum ScopeNames {
48 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', 50 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
49 WITH_ACCOUNT = 'WITH_ACCOUNT', 51 WITH_ACCOUNT = 'WITH_ACCOUNT',
50 WITH_ACTOR = 'WITH_ACTOR', 52 WITH_ACTOR = 'WITH_ACTOR',
51 WITH_VIDEOS = 'WITH_VIDEOS' 53 WITH_VIDEOS = 'WITH_VIDEOS',
54 SUMMARY = 'SUMMARY'
52} 55}
53 56
54type AvailableForListOptions = { 57type AvailableForListOptions = {
@@ -64,15 +67,41 @@ type AvailableForListOptions = {
64 ] 67 ]
65}) 68})
66@Scopes({ 69@Scopes({
67 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { 70 [ScopeNames.SUMMARY]: (required: boolean, withAccount: boolean) => {
68 const actorIdNumber = parseInt(options.actorId + '', 10) 71 const base: IFindOptions<VideoChannelModel> = {
72 attributes: [ 'name', 'description', 'id' ],
73 include: [
74 {
75 attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
76 model: ActorModel.unscoped(),
77 required: true,
78 include: [
79 {
80 attributes: [ 'host' ],
81 model: ServerModel.unscoped(),
82 required: false
83 },
84 {
85 model: AvatarModel.unscoped(),
86 required: false
87 }
88 ]
89 }
90 ]
91 }
92
93 if (withAccount === true) {
94 base.include.push({
95 model: AccountModel.scope(AccountModelScopeNames.SUMMARY),
96 required: true
97 })
98 }
69 99
100 return base
101 },
102 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
70 // Only list local channels OR channels that are on an instance followed by actorId 103 // Only list local channels OR channels that are on an instance followed by actorId
71 const inQueryInstanceFollow = '(' + 104 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
72 'SELECT "actor"."serverId" FROM "actorFollow" ' +
73 'INNER JOIN "actor" ON actor.id= "actorFollow"."targetActorId" ' +
74 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
75 ')'
76 105
77 return { 106 return {
78 include: [ 107 include: [
@@ -192,6 +221,15 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
192 }) 221 })
193 Videos: VideoModel[] 222 Videos: VideoModel[]
194 223
224 @HasMany(() => VideoPlaylistModel, {
225 foreignKey: {
226 allowNull: false
227 },
228 onDelete: 'cascade',
229 hooks: true
230 })
231 VideoPlaylists: VideoPlaylistModel[]
232
195 @BeforeDestroy 233 @BeforeDestroy
196 static async sendDeleteIfOwned (instance: VideoChannelModel, options) { 234 static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
197 if (!instance.Actor) { 235 if (!instance.Actor) {
@@ -460,6 +498,20 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
460 return Object.assign(actor, videoChannel) 498 return Object.assign(actor, videoChannel)
461 } 499 }
462 500
501 toFormattedSummaryJSON (): VideoChannelSummary {
502 const actor = this.Actor.toFormattedJSON()
503
504 return {
505 id: this.id,
506 uuid: actor.uuid,
507 name: actor.name,
508 displayName: this.getDisplayName(),
509 url: actor.url,
510 host: actor.host,
511 avatar: actor.avatar
512 }
513 }
514
463 toActivityPubObject (): ActivityPubActor { 515 toActivityPubObject (): ActivityPubActor {
464 const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel') 516 const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')
465 517
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index a62335333..dc10fb9a2 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/video-format-utils.ts
@@ -26,12 +26,10 @@ export type VideoFormattingJSONOptions = {
26 waitTranscoding?: boolean, 26 waitTranscoding?: boolean,
27 scheduledUpdate?: boolean, 27 scheduledUpdate?: boolean,
28 blacklistInfo?: boolean 28 blacklistInfo?: boolean
29 playlistInfo?: boolean
29 } 30 }
30} 31}
31function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video { 32function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
32 const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
33 const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
34
35 const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined 33 const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
36 34
37 const videoObject: Video = { 35 const videoObject: Video = {
@@ -68,24 +66,9 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
68 updatedAt: video.updatedAt, 66 updatedAt: video.updatedAt,
69 publishedAt: video.publishedAt, 67 publishedAt: video.publishedAt,
70 originallyPublishedAt: video.originallyPublishedAt, 68 originallyPublishedAt: video.originallyPublishedAt,
71 account: { 69
72 id: formattedAccount.id, 70 account: video.VideoChannel.Account.toFormattedSummaryJSON(),
73 uuid: formattedAccount.uuid, 71 channel: video.VideoChannel.toFormattedSummaryJSON(),
74 name: formattedAccount.name,
75 displayName: formattedAccount.displayName,
76 url: formattedAccount.url,
77 host: formattedAccount.host,
78 avatar: formattedAccount.avatar
79 },
80 channel: {
81 id: formattedVideoChannel.id,
82 uuid: formattedVideoChannel.uuid,
83 name: formattedVideoChannel.name,
84 displayName: formattedVideoChannel.displayName,
85 url: formattedVideoChannel.url,
86 host: formattedVideoChannel.host,
87 avatar: formattedVideoChannel.avatar
88 },
89 72
90 userHistory: userHistory ? { 73 userHistory: userHistory ? {
91 currentTime: userHistory.currentTime 74 currentTime: userHistory.currentTime
@@ -115,6 +98,17 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
115 videoObject.blacklisted = !!video.VideoBlacklist 98 videoObject.blacklisted = !!video.VideoBlacklist
116 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null 99 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
117 } 100 }
101
102 if (options.additionalAttributes.playlistInfo === true) {
103 // We filtered on a specific videoId/videoPlaylistId, that is unique
104 const playlistElement = video.VideoPlaylistElements[0]
105
106 videoObject.playlistElement = {
107 position: playlistElement.position,
108 startTimestamp: playlistElement.startTimestamp,
109 stopTimestamp: playlistElement.stopTimestamp
110 }
111 }
118 } 112 }
119 113
120 return videoObject 114 return videoObject
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
new file mode 100644
index 000000000..d76149d12
--- /dev/null
+++ b/server/models/video/video-playlist-element.ts
@@ -0,0 +1,231 @@
1import {
2 AllowNull,
3 BelongsTo,
4 Column,
5 CreatedAt,
6 DataType,
7 Default,
8 ForeignKey,
9 Is,
10 IsInt,
11 Min,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { VideoModel } from './video'
17import { VideoPlaylistModel } from './video-playlist'
18import * as Sequelize from 'sequelize'
19import { getSort, throwIfNotValid } from '../utils'
20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
21import { CONSTRAINTS_FIELDS } from '../../initializers'
22import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
23
24@Table({
25 tableName: 'videoPlaylistElement',
26 indexes: [
27 {
28 fields: [ 'videoPlaylistId' ]
29 },
30 {
31 fields: [ 'videoId' ]
32 },
33 {
34 fields: [ 'videoPlaylistId', 'videoId' ],
35 unique: true
36 },
37 {
38 fields: [ 'videoPlaylistId', 'position' ],
39 unique: true
40 },
41 {
42 fields: [ 'url' ],
43 unique: true
44 }
45 ]
46})
47export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel> {
48 @CreatedAt
49 createdAt: Date
50
51 @UpdatedAt
52 updatedAt: Date
53
54 @AllowNull(false)
55 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
56 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
57 url: string
58
59 @AllowNull(false)
60 @Default(1)
61 @IsInt
62 @Min(1)
63 @Column
64 position: number
65
66 @AllowNull(true)
67 @IsInt
68 @Min(0)
69 @Column
70 startTimestamp: number
71
72 @AllowNull(true)
73 @IsInt
74 @Min(0)
75 @Column
76 stopTimestamp: number
77
78 @ForeignKey(() => VideoPlaylistModel)
79 @Column
80 videoPlaylistId: number
81
82 @BelongsTo(() => VideoPlaylistModel, {
83 foreignKey: {
84 allowNull: false
85 },
86 onDelete: 'CASCADE'
87 })
88 VideoPlaylist: VideoPlaylistModel
89
90 @ForeignKey(() => VideoModel)
91 @Column
92 videoId: number
93
94 @BelongsTo(() => VideoModel, {
95 foreignKey: {
96 allowNull: false
97 },
98 onDelete: 'CASCADE'
99 })
100 Video: VideoModel
101
102 static deleteAllOf (videoPlaylistId: number, transaction?: Sequelize.Transaction) {
103 const query = {
104 where: {
105 videoPlaylistId
106 },
107 transaction
108 }
109
110 return VideoPlaylistElementModel.destroy(query)
111 }
112
113 static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number) {
114 const query = {
115 where: {
116 videoPlaylistId,
117 videoId
118 }
119 }
120
121 return VideoPlaylistElementModel.findOne(query)
122 }
123
124 static loadByPlaylistAndVideoForAP (playlistId: number | string, videoId: number | string) {
125 const playlistWhere = validator.isUUID('' + playlistId) ? { uuid: playlistId } : { id: playlistId }
126 const videoWhere = validator.isUUID('' + videoId) ? { uuid: videoId } : { id: videoId }
127
128 const query = {
129 include: [
130 {
131 attributes: [ 'privacy' ],
132 model: VideoPlaylistModel.unscoped(),
133 where: playlistWhere
134 },
135 {
136 attributes: [ 'url' ],
137 model: VideoModel.unscoped(),
138 where: videoWhere
139 }
140 ]
141 }
142
143 return VideoPlaylistElementModel.findOne(query)
144 }
145
146 static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number) {
147 const query = {
148 attributes: [ 'url' ],
149 offset: start,
150 limit: count,
151 order: getSort('position'),
152 where: {
153 videoPlaylistId
154 }
155 }
156
157 return VideoPlaylistElementModel
158 .findAndCountAll(query)
159 .then(({ rows, count }) => {
160 return { total: count, data: rows.map(e => e.url) }
161 })
162 }
163
164 static getNextPositionOf (videoPlaylistId: number, transaction?: Sequelize.Transaction) {
165 const query = {
166 where: {
167 videoPlaylistId
168 },
169 transaction
170 }
171
172 return VideoPlaylistElementModel.max('position', query)
173 .then(position => position ? position + 1 : 1)
174 }
175
176 static reassignPositionOf (
177 videoPlaylistId: number,
178 firstPosition: number,
179 endPosition: number,
180 newPosition: number,
181 transaction?: Sequelize.Transaction
182 ) {
183 const query = {
184 where: {
185 videoPlaylistId,
186 position: {
187 [Sequelize.Op.gte]: firstPosition,
188 [Sequelize.Op.lte]: endPosition
189 }
190 },
191 transaction
192 }
193
194 return VideoPlaylistElementModel.update({ position: Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`) }, query)
195 }
196
197 static increasePositionOf (
198 videoPlaylistId: number,
199 fromPosition: number,
200 toPosition?: number,
201 by = 1,
202 transaction?: Sequelize.Transaction
203 ) {
204 const query = {
205 where: {
206 videoPlaylistId,
207 position: {
208 [Sequelize.Op.gte]: fromPosition
209 }
210 },
211 transaction
212 }
213
214 return VideoPlaylistElementModel.increment({ position: by }, query)
215 }
216
217 toActivityPubObject (): PlaylistElementObject {
218 const base: PlaylistElementObject = {
219 id: this.url,
220 type: 'PlaylistElement',
221
222 url: this.Video.url,
223 position: this.position
224 }
225
226 if (this.startTimestamp) base.startTimestamp = this.startTimestamp
227 if (this.stopTimestamp) base.stopTimestamp = this.stopTimestamp
228
229 return base
230 }
231}
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
new file mode 100644
index 000000000..93b8c2f58
--- /dev/null
+++ b/server/models/video/video-playlist.ts
@@ -0,0 +1,381 @@
1import {
2 AllowNull,
3 BeforeDestroy,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 ForeignKey,
10 HasMany,
11 Is,
12 IsUUID,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import * as Sequelize from 'sequelize'
19import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
20import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, throwIfNotValid } from '../utils'
21import {
22 isVideoPlaylistDescriptionValid,
23 isVideoPlaylistNameValid,
24 isVideoPlaylistPrivacyValid
25} from '../../helpers/custom-validators/video-playlists'
26import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
27import { CONFIG, CONSTRAINTS_FIELDS, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers'
28import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
29import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
30import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
31import { join } from 'path'
32import { VideoPlaylistElementModel } from './video-playlist-element'
33import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
34import { activityPubCollectionPagination } from '../../helpers/activitypub'
35import { remove } from 'fs-extra'
36import { logger } from '../../helpers/logger'
37
38enum ScopeNames {
39 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
40 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
41 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
42}
43
44type AvailableForListOptions = {
45 followerActorId: number
46 accountId?: number,
47 videoChannelId?: number
48 privateAndUnlisted?: boolean
49}
50
51@Scopes({
52 [ScopeNames.WITH_VIDEOS_LENGTH]: {
53 attributes: {
54 include: [
55 [
56 Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
57 'videosLength'
58 ]
59 ]
60 }
61 },
62 [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: {
63 include: [
64 {
65 model: () => AccountModel.scope(AccountScopeNames.SUMMARY),
66 required: true
67 },
68 {
69 model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
70 required: false
71 }
72 ]
73 },
74 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
75 // Only list local playlists OR playlists that are on an instance followed by actorId
76 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
77 const actorWhere = {
78 [ Sequelize.Op.or ]: [
79 {
80 serverId: null
81 },
82 {
83 serverId: {
84 [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow)
85 }
86 }
87 ]
88 }
89
90 const whereAnd: any[] = []
91
92 if (options.privateAndUnlisted !== true) {
93 whereAnd.push({
94 privacy: VideoPlaylistPrivacy.PUBLIC
95 })
96 }
97
98 if (options.accountId) {
99 whereAnd.push({
100 ownerAccountId: options.accountId
101 })
102 }
103
104 if (options.videoChannelId) {
105 whereAnd.push({
106 videoChannelId: options.videoChannelId
107 })
108 }
109
110 const where = {
111 [Sequelize.Op.and]: whereAnd
112 }
113
114 const accountScope = {
115 method: [ AccountScopeNames.SUMMARY, actorWhere ]
116 }
117
118 return {
119 where,
120 include: [
121 {
122 model: AccountModel.scope(accountScope),
123 required: true
124 },
125 {
126 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
127 required: false
128 }
129 ]
130 }
131 }
132})
133
134@Table({
135 tableName: 'videoPlaylist',
136 indexes: [
137 {
138 fields: [ 'ownerAccountId' ]
139 },
140 {
141 fields: [ 'videoChannelId' ]
142 },
143 {
144 fields: [ 'url' ],
145 unique: true
146 }
147 ]
148})
149export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
150 @CreatedAt
151 createdAt: Date
152
153 @UpdatedAt
154 updatedAt: Date
155
156 @AllowNull(false)
157 @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
158 @Column
159 name: string
160
161 @AllowNull(true)
162 @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description'))
163 @Column
164 description: string
165
166 @AllowNull(false)
167 @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
168 @Column
169 privacy: VideoPlaylistPrivacy
170
171 @AllowNull(false)
172 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
173 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
174 url: string
175
176 @AllowNull(false)
177 @Default(DataType.UUIDV4)
178 @IsUUID(4)
179 @Column(DataType.UUID)
180 uuid: string
181
182 @ForeignKey(() => AccountModel)
183 @Column
184 ownerAccountId: number
185
186 @BelongsTo(() => AccountModel, {
187 foreignKey: {
188 allowNull: false
189 },
190 onDelete: 'CASCADE'
191 })
192 OwnerAccount: AccountModel
193
194 @ForeignKey(() => VideoChannelModel)
195 @Column
196 videoChannelId: number
197
198 @BelongsTo(() => VideoChannelModel, {
199 foreignKey: {
200 allowNull: false
201 },
202 onDelete: 'CASCADE'
203 })
204 VideoChannel: VideoChannelModel
205
206 @HasMany(() => VideoPlaylistElementModel, {
207 foreignKey: {
208 name: 'videoPlaylistId',
209 allowNull: false
210 },
211 onDelete: 'cascade'
212 })
213 VideoPlaylistElements: VideoPlaylistElementModel[]
214
215 // Calculated field
216 videosLength?: number
217
218 @BeforeDestroy
219 static async removeFiles (instance: VideoPlaylistModel) {
220 logger.info('Removing files of video playlist %s.', instance.url)
221
222 return instance.removeThumbnail()
223 }
224
225 static listForApi (options: {
226 followerActorId: number
227 start: number,
228 count: number,
229 sort: string,
230 accountId?: number,
231 videoChannelId?: number,
232 privateAndUnlisted?: boolean
233 }) {
234 const query = {
235 offset: options.start,
236 limit: options.count,
237 order: getSort(options.sort)
238 }
239
240 const scopes = [
241 {
242 method: [
243 ScopeNames.AVAILABLE_FOR_LIST,
244 {
245 followerActorId: options.followerActorId,
246 accountId: options.accountId,
247 videoChannelId: options.videoChannelId,
248 privateAndUnlisted: options.privateAndUnlisted
249 } as AvailableForListOptions
250 ]
251 } as any, // FIXME: typings
252 ScopeNames.WITH_VIDEOS_LENGTH
253 ]
254
255 return VideoPlaylistModel
256 .scope(scopes)
257 .findAndCountAll(query)
258 .then(({ rows, count }) => {
259 return { total: count, data: rows }
260 })
261 }
262
263 static listUrlsOfForAP (accountId: number, start: number, count: number) {
264 const query = {
265 attributes: [ 'url' ],
266 offset: start,
267 limit: count,
268 where: {
269 ownerAccountId: accountId
270 }
271 }
272
273 return VideoPlaylistModel.findAndCountAll(query)
274 .then(({ rows, count }) => {
275 return { total: count, data: rows.map(p => p.url) }
276 })
277 }
278
279 static doesPlaylistExist (url: string) {
280 const query = {
281 attributes: [],
282 where: {
283 url
284 }
285 }
286
287 return VideoPlaylistModel
288 .findOne(query)
289 .then(e => !!e)
290 }
291
292 static load (id: number | string, transaction: Sequelize.Transaction) {
293 const where = buildWhereIdOrUUID(id)
294
295 const query = {
296 where,
297 transaction
298 }
299
300 return VideoPlaylistModel
301 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ])
302 .findOne(query)
303 }
304
305 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
306 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
307 }
308
309 getThumbnailName () {
310 const extension = '.jpg'
311
312 return 'playlist-' + this.uuid + extension
313 }
314
315 getThumbnailUrl () {
316 return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
317 }
318
319 getThumbnailStaticPath () {
320 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
321 }
322
323 removeThumbnail () {
324 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
325 return remove(thumbnailPath)
326 .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
327 }
328
329 isOwned () {
330 return this.OwnerAccount.isOwned()
331 }
332
333 toFormattedJSON (): VideoPlaylist {
334 return {
335 id: this.id,
336 uuid: this.uuid,
337 isLocal: this.isOwned(),
338
339 displayName: this.name,
340 description: this.description,
341 privacy: {
342 id: this.privacy,
343 label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
344 },
345
346 thumbnailPath: this.getThumbnailStaticPath(),
347
348 videosLength: this.videosLength,
349
350 createdAt: this.createdAt,
351 updatedAt: this.updatedAt,
352
353 ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
354 videoChannel: this.VideoChannel.toFormattedSummaryJSON()
355 }
356 }
357
358 toActivityPubObject (): Promise<PlaylistObject> {
359 const handler = (start: number, count: number) => {
360 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count)
361 }
362
363 return activityPubCollectionPagination(this.url, handler, null)
364 .then(o => {
365 return Object.assign(o, {
366 type: 'Playlist' as 'Playlist',
367 name: this.name,
368 content: this.description,
369 uuid: this.uuid,
370 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
371 icon: {
372 type: 'Image' as 'Image',
373 url: this.getThumbnailUrl(),
374 mediaType: 'image/jpeg' as 'image/jpeg',
375 width: THUMBNAILS_SIZE.width,
376 height: THUMBNAILS_SIZE.height
377 }
378 })
379 })
380 }
381}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 4516b9c7b..7a102b058 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -40,7 +40,7 @@ import {
40 isVideoDurationValid, 40 isVideoDurationValid,
41 isVideoLanguageValid, 41 isVideoLanguageValid,
42 isVideoLicenceValid, 42 isVideoLicenceValid,
43 isVideoNameValid, isVideoOriginallyPublishedAtValid, 43 isVideoNameValid,
44 isVideoPrivacyValid, 44 isVideoPrivacyValid,
45 isVideoStateValid, 45 isVideoStateValid,
46 isVideoSupportValid 46 isVideoSupportValid
@@ -52,7 +52,9 @@ import {
52 ACTIVITY_PUB, 52 ACTIVITY_PUB,
53 API_VERSION, 53 API_VERSION,
54 CONFIG, 54 CONFIG,
55 CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY, 55 CONSTRAINTS_FIELDS,
56 HLS_PLAYLIST_DIRECTORY,
57 HLS_REDUNDANCY_DIRECTORY,
56 PREVIEWS_SIZE, 58 PREVIEWS_SIZE,
57 REMOTE_SCHEME, 59 REMOTE_SCHEME,
58 STATIC_DOWNLOAD_PATHS, 60 STATIC_DOWNLOAD_PATHS,
@@ -70,10 +72,17 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
70import { ActorModel } from '../activitypub/actor' 72import { ActorModel } from '../activitypub/actor'
71import { AvatarModel } from '../avatar/avatar' 73import { AvatarModel } from '../avatar/avatar'
72import { ServerModel } from '../server/server' 74import { ServerModel } from '../server/server'
73import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' 75import {
76 buildBlockedAccountSQL,
77 buildTrigramSearchIndex,
78 buildWhereIdOrUUID,
79 createSimilarityAttribute,
80 getVideoSort,
81 throwIfNotValid
82} from '../utils'
74import { TagModel } from './tag' 83import { TagModel } from './tag'
75import { VideoAbuseModel } from './video-abuse' 84import { VideoAbuseModel } from './video-abuse'
76import { VideoChannelModel } from './video-channel' 85import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel'
77import { VideoCommentModel } from './video-comment' 86import { VideoCommentModel } from './video-comment'
78import { VideoFileModel } from './video-file' 87import { VideoFileModel } from './video-file'
79import { VideoShareModel } from './video-share' 88import { VideoShareModel } from './video-share'
@@ -91,11 +100,11 @@ import {
91 videoModelToFormattedDetailsJSON, 100 videoModelToFormattedDetailsJSON,
92 videoModelToFormattedJSON 101 videoModelToFormattedJSON
93} from './video-format-utils' 102} from './video-format-utils'
94import * as validator from 'validator'
95import { UserVideoHistoryModel } from '../account/user-video-history' 103import { UserVideoHistoryModel } from '../account/user-video-history'
96import { UserModel } from '../account/user' 104import { UserModel } from '../account/user'
97import { VideoImportModel } from './video-import' 105import { VideoImportModel } from './video-import'
98import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 106import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
107import { VideoPlaylistElementModel } from './video-playlist-element'
99 108
100// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 109// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
101const indexes: Sequelize.DefineIndexesOptions[] = [ 110const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -175,6 +184,9 @@ export enum ScopeNames {
175 184
176type ForAPIOptions = { 185type ForAPIOptions = {
177 ids: number[] 186 ids: number[]
187
188 videoPlaylistId?: number
189
178 withFiles?: boolean 190 withFiles?: boolean
179} 191}
180 192
@@ -182,6 +194,7 @@ type AvailableForListIDsOptions = {
182 serverAccountId: number 194 serverAccountId: number
183 followerActorId: number 195 followerActorId: number
184 includeLocalVideos: boolean 196 includeLocalVideos: boolean
197
185 filter?: VideoFilter 198 filter?: VideoFilter
186 categoryOneOf?: number[] 199 categoryOneOf?: number[]
187 nsfw?: boolean 200 nsfw?: boolean
@@ -189,9 +202,14 @@ type AvailableForListIDsOptions = {
189 languageOneOf?: string[] 202 languageOneOf?: string[]
190 tagsOneOf?: string[] 203 tagsOneOf?: string[]
191 tagsAllOf?: string[] 204 tagsAllOf?: string[]
205
192 withFiles?: boolean 206 withFiles?: boolean
207
193 accountId?: number 208 accountId?: number
194 videoChannelId?: number 209 videoChannelId?: number
210
211 videoPlaylistId?: number
212
195 trendingDays?: number 213 trendingDays?: number
196 user?: UserModel, 214 user?: UserModel,
197 historyOfUser?: UserModel 215 historyOfUser?: UserModel
@@ -199,62 +217,17 @@ type AvailableForListIDsOptions = {
199 217
200@Scopes({ 218@Scopes({
201 [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => { 219 [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
202 const accountInclude = {
203 attributes: [ 'id', 'name' ],
204 model: AccountModel.unscoped(),
205 required: true,
206 include: [
207 {
208 attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
209 model: ActorModel.unscoped(),
210 required: true,
211 include: [
212 {
213 attributes: [ 'host' ],
214 model: ServerModel.unscoped(),
215 required: false
216 },
217 {
218 model: AvatarModel.unscoped(),
219 required: false
220 }
221 ]
222 }
223 ]
224 }
225
226 const videoChannelInclude = {
227 attributes: [ 'name', 'description', 'id' ],
228 model: VideoChannelModel.unscoped(),
229 required: true,
230 include: [
231 {
232 attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
233 model: ActorModel.unscoped(),
234 required: true,
235 include: [
236 {
237 attributes: [ 'host' ],
238 model: ServerModel.unscoped(),
239 required: false
240 },
241 {
242 model: AvatarModel.unscoped(),
243 required: false
244 }
245 ]
246 },
247 accountInclude
248 ]
249 }
250
251 const query: IFindOptions<VideoModel> = { 220 const query: IFindOptions<VideoModel> = {
252 where: { 221 where: {
253 id: { 222 id: {
254 [ Sequelize.Op.any ]: options.ids 223 [ Sequelize.Op.any ]: options.ids
255 } 224 }
256 }, 225 },
257 include: [ videoChannelInclude ] 226 include: [
227 {
228 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY)
229 }
230 ]
258 } 231 }
259 232
260 if (options.withFiles === true) { 233 if (options.withFiles === true) {
@@ -264,6 +237,13 @@ type AvailableForListIDsOptions = {
264 }) 237 })
265 } 238 }
266 239
240 if (options.videoPlaylistId) {
241 query.include.push({
242 model: VideoPlaylistElementModel.unscoped(),
243 required: true
244 })
245 }
246
267 return query 247 return query
268 }, 248 },
269 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { 249 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
@@ -315,6 +295,17 @@ type AvailableForListIDsOptions = {
315 Object.assign(query.where, privacyWhere) 295 Object.assign(query.where, privacyWhere)
316 } 296 }
317 297
298 if (options.videoPlaylistId) {
299 query.include.push({
300 attributes: [],
301 model: VideoPlaylistElementModel.unscoped(),
302 required: true,
303 where: {
304 videoPlaylistId: options.videoPlaylistId
305 }
306 })
307 }
308
318 if (options.filter || options.accountId || options.videoChannelId) { 309 if (options.filter || options.accountId || options.videoChannelId) {
319 const videoChannelInclude: IIncludeOptions = { 310 const videoChannelInclude: IIncludeOptions = {
320 attributes: [], 311 attributes: [],
@@ -772,6 +763,15 @@ export class VideoModel extends Model<VideoModel> {
772 }) 763 })
773 Tags: TagModel[] 764 Tags: TagModel[]
774 765
766 @HasMany(() => VideoPlaylistElementModel, {
767 foreignKey: {
768 name: 'videoId',
769 allowNull: false
770 },
771 onDelete: 'cascade'
772 })
773 VideoPlaylistElements: VideoPlaylistElementModel[]
774
775 @HasMany(() => VideoAbuseModel, { 775 @HasMany(() => VideoAbuseModel, {
776 foreignKey: { 776 foreignKey: {
777 name: 'videoId', 777 name: 'videoId',
@@ -1118,6 +1118,7 @@ export class VideoModel extends Model<VideoModel> {
1118 accountId?: number, 1118 accountId?: number,
1119 videoChannelId?: number, 1119 videoChannelId?: number,
1120 followerActorId?: number 1120 followerActorId?: number
1121 videoPlaylistId?: number,
1121 trendingDays?: number, 1122 trendingDays?: number,
1122 user?: UserModel, 1123 user?: UserModel,
1123 historyOfUser?: UserModel 1124 historyOfUser?: UserModel
@@ -1157,6 +1158,7 @@ export class VideoModel extends Model<VideoModel> {
1157 withFiles: options.withFiles, 1158 withFiles: options.withFiles,
1158 accountId: options.accountId, 1159 accountId: options.accountId,
1159 videoChannelId: options.videoChannelId, 1160 videoChannelId: options.videoChannelId,
1161 videoPlaylistId: options.videoPlaylistId,
1160 includeLocalVideos: options.includeLocalVideos, 1162 includeLocalVideos: options.includeLocalVideos,
1161 user: options.user, 1163 user: options.user,
1162 historyOfUser: options.historyOfUser, 1164 historyOfUser: options.historyOfUser,
@@ -1280,7 +1282,7 @@ export class VideoModel extends Model<VideoModel> {
1280 } 1282 }
1281 1283
1282 static load (id: number | string, t?: Sequelize.Transaction) { 1284 static load (id: number | string, t?: Sequelize.Transaction) {
1283 const where = VideoModel.buildWhereIdOrUUID(id) 1285 const where = buildWhereIdOrUUID(id)
1284 const options = { 1286 const options = {
1285 where, 1287 where,
1286 transaction: t 1288 transaction: t
@@ -1290,7 +1292,7 @@ export class VideoModel extends Model<VideoModel> {
1290 } 1292 }
1291 1293
1292 static loadWithRights (id: number | string, t?: Sequelize.Transaction) { 1294 static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
1293 const where = VideoModel.buildWhereIdOrUUID(id) 1295 const where = buildWhereIdOrUUID(id)
1294 const options = { 1296 const options = {
1295 where, 1297 where,
1296 transaction: t 1298 transaction: t
@@ -1300,7 +1302,7 @@ export class VideoModel extends Model<VideoModel> {
1300 } 1302 }
1301 1303
1302 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { 1304 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
1303 const where = VideoModel.buildWhereIdOrUUID(id) 1305 const where = buildWhereIdOrUUID(id)
1304 1306
1305 const options = { 1307 const options = {
1306 attributes: [ 'id' ], 1308 attributes: [ 'id' ],
@@ -1353,7 +1355,7 @@ export class VideoModel extends Model<VideoModel> {
1353 } 1355 }
1354 1356
1355 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { 1357 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
1356 const where = VideoModel.buildWhereIdOrUUID(id) 1358 const where = buildWhereIdOrUUID(id)
1357 1359
1358 const options = { 1360 const options = {
1359 order: [ [ 'Tags', 'name', 'ASC' ] ], 1361 order: [ [ 'Tags', 'name', 'ASC' ] ],
@@ -1380,7 +1382,7 @@ export class VideoModel extends Model<VideoModel> {
1380 } 1382 }
1381 1383
1382 static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) { 1384 static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) {
1383 const where = VideoModel.buildWhereIdOrUUID(id) 1385 const where = buildWhereIdOrUUID(id)
1384 1386
1385 const options = { 1387 const options = {
1386 order: [ [ 'Tags', 'name', 'ASC' ] ], 1388 order: [ [ 'Tags', 'name', 'ASC' ] ],
@@ -1582,10 +1584,6 @@ export class VideoModel extends Model<VideoModel> {
1582 return VIDEO_STATES[ id ] || 'Unknown' 1584 return VIDEO_STATES[ id ] || 'Unknown'
1583 } 1585 }
1584 1586
1585 static buildWhereIdOrUUID (id: number | string) {
1586 return validator.isInt('' + id) ? { id } : { uuid: id }
1587 }
1588
1589 getOriginalFile () { 1587 getOriginalFile () {
1590 if (Array.isArray(this.VideoFiles) === false) return undefined 1588 if (Array.isArray(this.VideoFiles) === false) return undefined
1591 1589
@@ -1598,7 +1596,6 @@ export class VideoModel extends Model<VideoModel> {
1598 } 1596 }
1599 1597
1600 getThumbnailName () { 1598 getThumbnailName () {
1601 // We always have a copy of the thumbnail
1602 const extension = '.jpg' 1599 const extension = '.jpg'
1603 return this.uuid + extension 1600 return this.uuid + extension
1604 } 1601 }
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
3import { omit } from 'lodash'
4import 'mocha'
5import { join } from 'path'
6import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum'
7import {
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'
22import {
23 checkBadCountPagination,
24 checkBadSortPagination,
25 checkBadStartPagination
26} from '../../../../shared/utils/requests/check-api-params'
27import { getMagnetURI, getYoutubeVideoUrl } from '../../../../shared/utils/videos/video-imports'
28
29describe('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
3import * as chai from 'chai'
4import 'mocha'
5import { join } from 'path'
6import * as request from 'supertest'
7import { VideoPrivacy } from '../../../../shared/models/videos'
8import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
9import {
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'
36import {
37 addVideoCommentReply,
38 addVideoCommentThread,
39 deleteVideoComment,
40 getVideoCommentThreads,
41 getVideoThreadComments
42} from '../../../../shared/utils/videos/video-comments'
43import { waitJobs } from '../../../../shared/utils/server/jobs'
44
45const expect = chai.expect
46
47describe('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'
6import { VideoCommentObject } from './objects/video-comment-object' 6import { VideoCommentObject } from './objects/video-comment-object'
7import { ViewObject } from './objects/view-object' 7import { ViewObject } from './objects/view-object'
8import { APObject } from './objects/object.model' 8import { APObject } from './objects/object.model'
9import { PlaylistObject } from './objects/playlist-object'
9 10
10export type Activity = ActivityCreate | ActivityUpdate | 11export type Activity = ActivityCreate | ActivityUpdate |
11 ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce | 12 ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce |
@@ -31,12 +32,12 @@ export interface BaseActivity {
31 32
32export interface ActivityCreate extends BaseActivity { 33export 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
37export interface ActivityUpdate extends BaseActivity { 38export 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
42export interface ActivityDelete extends BaseActivity { 43export 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 @@
1export 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 @@
1import { ActivityIconObject } from './common-objects'
2
3export 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 @@
1import { Actor } from './actor.model' 1import { Actor } from './actor.model'
2import { Avatar } from '../avatars'
2 3
3export interface Account extends Actor { 4export 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
11export 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 @@
1import { Video, VideoChannelAttribute, VideoConstant } from '../videos' 1import { Video, VideoChannelSummary, VideoConstant } from '../videos'
2 2
3export interface VideosOverview { 3export 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 @@
1import { Actor } from '../../actors/actor.model' 1import { Actor } from '../../actors/actor.model'
2import { Video } from '../video.model'
3import { Account } from '../../actors/index' 2import { Account } from '../../actors/index'
3import { Avatar } from '../../avatars'
4 4
5export interface VideoChannel extends Actor { 5export 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
13export 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 @@
1import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
2
3export 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 @@
1export 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 @@
1export 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 @@
1export 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 @@
1import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
2
3export 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 @@
1import { AccountSummary } from '../../actors/index'
2import { VideoChannelSummary, VideoConstant } from '..'
3import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
4
5export 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 @@
1import { VideoResolution, VideoState } from '../../index' 1import { AccountSummary, VideoChannelSummary, VideoResolution, VideoState } from '../../index'
2import { Account } from '../actors' 2import { Account } from '../actors'
3import { Avatar } from '../avatars/avatar.model' 3import { Avatar } from '../avatars/avatar.model'
4import { VideoChannel } from './channel/video-channel.model' 4import { VideoChannel } from './channel/video-channel.model'
@@ -18,26 +18,6 @@ export interface VideoFile {
18 fps: number 18 fps: number
19} 19}
20 20
21export 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
31export 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
41export interface Video { 21export 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
79export interface VideoDetails extends Video { 65export 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 @@
1import { makeRawRequest } from '../requests/requests' 1import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest, makeUploadRequest } from '../requests/requests'
2import { sha256 } from '../../../server/helpers/core-utils' 2import { VideoPlaylistCreate } from '../../models/videos/playlist/video-playlist-create.model'
3import { VideoStreamingPlaylist } from '../../models/videos/video-streaming-playlist.model' 3import { omit } from 'lodash'
4import { expect } from 'chai' 4import { VideoPlaylistUpdate } from '../../models/videos/playlist/video-playlist-update.model'
5import { VideoPlaylistElementCreate } from '../../models/videos/playlist/video-playlist-element-create.model'
6import { VideoPlaylistElementUpdate } from '../../models/videos/playlist/video-playlist-element-update.model'
5 7
6function getPlaylist (url: string, statusCodeExpected = 200) { 8function 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
10function getSegment (url: string, statusCodeExpected = 200, range?: string) { 24function 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
14function getSegmentSha256 (url: string, statusCodeExpected = 200) { 34function 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
18async function checkSegmentHash ( 45function 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) 70function 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
95function 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
113function 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
132function 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
149function 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
46export { 173export {
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 @@
1import { makeRawRequest } from '../requests/requests'
2import { sha256 } from '../../../server/helpers/core-utils'
3import { VideoStreamingPlaylist } from '../../models/videos/video-streaming-playlist.model'
4import { expect } from 'chai'
5
6function getPlaylist (url: string, statusCodeExpected = 200) {
7 return makeRawRequest(url, statusCodeExpected)
8}
9
10function getSegment (url: string, statusCodeExpected = 200, range?: string) {
11 return makeRawRequest(url, statusCodeExpected, range)
12}
13
14function getSegmentSha256 (url: string, statusCodeExpected = 200) {
15 return makeRawRequest(url, statusCodeExpected)
16}
17
18async 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
46export {
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
226function 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
226function getVideosListPagination (url: string, start: number, count: number, sort?: string) { 248function 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}