Playlist server API
authorChocobozzz <me@florianbigard.com>
Tue, 26 Feb 2019 09:55:40 +0000 (10:55 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Mon, 18 Mar 2019 10:17:59 +0000 (11:17 +0100)
63 files changed:
server.ts
server/controllers/activitypub/client.ts
server/controllers/activitypub/outbox.ts
server/controllers/api/accounts.ts
server/controllers/api/index.ts
server/controllers/api/video-channel.ts
server/controllers/api/video-playlist.ts [new file with mode: 0644]
server/controllers/api/videos/blacklist.ts
server/controllers/services.ts
server/helpers/activitypub.ts
server/helpers/custom-validators/activitypub/activity.ts
server/helpers/custom-validators/activitypub/playlist.ts [new file with mode: 0644]
server/helpers/custom-validators/video-playlists.ts [new file with mode: 0644]
server/helpers/custom-validators/videos.ts
server/initializers/constants.ts
server/initializers/database.ts
server/lib/activitypub/actor.ts
server/lib/activitypub/cache-file.ts
server/lib/activitypub/crawl.ts
server/lib/activitypub/playlist.ts [new file with mode: 0644]
server/lib/activitypub/process/process-create.ts
server/lib/activitypub/process/process-update.ts
server/lib/activitypub/send/send-create.ts
server/lib/activitypub/send/send-delete.ts
server/lib/activitypub/send/send-update.ts
server/lib/activitypub/url.ts
server/lib/job-queue/handlers/activitypub-http-fetcher.ts
server/middlewares/validators/account.ts
server/middlewares/validators/sort.ts
server/middlewares/validators/videos/video-channels.ts
server/middlewares/validators/videos/video-imports.ts
server/middlewares/validators/videos/video-playlists.ts [new file with mode: 0644]
server/middlewares/validators/videos/videos.ts
server/models/account/account.ts
server/models/activitypub/actor-follow.ts
server/models/activitypub/actor.ts
server/models/utils.ts
server/models/video/video-channel.ts
server/models/video/video-format-utils.ts
server/models/video/video-playlist-element.ts [new file with mode: 0644]
server/models/video/video-playlist.ts [new file with mode: 0644]
server/models/video/video.ts
server/tests/api/check-params/video-playlists.ts [new file with mode: 0644]
server/tests/api/videos/video-playlists.ts [new file with mode: 0644]
shared/models/activitypub/activity.ts
shared/models/activitypub/activitypub-actor.ts
shared/models/activitypub/objects/playlist-element-object.ts [new file with mode: 0644]
shared/models/activitypub/objects/playlist-object.ts [new file with mode: 0644]
shared/models/actors/account.model.ts
shared/models/overviews/videos-overview.ts
shared/models/users/user-right.enum.ts
shared/models/users/user-role.ts
shared/models/videos/channel/video-channel.model.ts
shared/models/videos/playlist/video-playlist-create.model.ts [new file with mode: 0644]
shared/models/videos/playlist/video-playlist-element-create.model.ts [new file with mode: 0644]
shared/models/videos/playlist/video-playlist-element-update.model.ts [new file with mode: 0644]
shared/models/videos/playlist/video-playlist-privacy.model.ts [new file with mode: 0644]
shared/models/videos/playlist/video-playlist-update.model.ts [new file with mode: 0644]
shared/models/videos/playlist/video-playlist.model.ts [new file with mode: 0644]
shared/models/videos/video.model.ts
shared/utils/videos/video-playlists.ts
shared/utils/videos/video-streaming-playlists.ts [new file with mode: 0644]
shared/utils/videos/videos.ts

index c450d5b6ece3a9707196373032774ba7859f1185..9fe7411755db749c35f8194754a8b62ed5578f7f 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -53,7 +53,7 @@ if (errorMessage !== null) {
 app.set('trust proxy', CONFIG.TRUST_PROXY)
 
 // Security middleware
-import { baseCSP } from './server/middlewares'
+import { baseCSP } from './server/middlewares/csp'
 
 if (CONFIG.CSP.ENABLED) {
   app.use(baseCSP)
index 31c0a5fbd8c04dc54b744a230ba170eaf2d3ba8f..59e6c8e9f0369387b455d8d279ddba33211816b9 100644 (file)
@@ -14,7 +14,7 @@ import {
   videosCustomGetValidator,
   videosShareValidator
 } from '../../middlewares'
-import { getAccountVideoRateValidator, videoCommentGetValidator, videosGetValidator } from '../../middlewares/validators'
+import { getAccountVideoRateValidator, videoCommentGetValidator } from '../../middlewares/validators'
 import { AccountModel } from '../../models/account/account'
 import { ActorModel } from '../../models/activitypub/actor'
 import { ActorFollowModel } from '../../models/activitypub/actor-follow'
@@ -37,6 +37,10 @@ import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator }
 import { getServerActor } from '../../helpers/utils'
 import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
 import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike'
+import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists'
+import { VideoPlaylistModel } from '../../models/video/video-playlist'
+import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
+import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
 
 const activityPubClientRouter = express.Router()
 
@@ -52,6 +56,10 @@ activityPubClientRouter.get('/accounts?/:name/following',
   executeIfActivityPub(asyncMiddleware(localAccountValidator)),
   executeIfActivityPub(asyncMiddleware(accountFollowingController))
 )
+activityPubClientRouter.get('/accounts?/:name/playlists',
+  executeIfActivityPub(asyncMiddleware(localAccountValidator)),
+  executeIfActivityPub(asyncMiddleware(accountPlaylistsController))
+)
 activityPubClientRouter.get('/accounts?/:name/likes/:videoId',
   executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('like'))),
   executeIfActivityPub(getAccountVideoRate('like'))
@@ -121,6 +129,15 @@ activityPubClientRouter.get('/redundancy/video-playlists/:streamingPlaylistType/
   executeIfActivityPub(asyncMiddleware(videoRedundancyController))
 )
 
+activityPubClientRouter.get('/video-playlists/:playlistId',
+  executeIfActivityPub(asyncMiddleware(videoPlaylistsGetValidator)),
+  executeIfActivityPub(asyncMiddleware(videoPlaylistController))
+)
+activityPubClientRouter.get('/video-playlists/:playlistId/:videoId',
+  executeIfActivityPub(asyncMiddleware(videoPlaylistElementAPGetValidator)),
+  executeIfActivityPub(asyncMiddleware(videoPlaylistElementController))
+)
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -129,26 +146,33 @@ export {
 
 // ---------------------------------------------------------------------------
 
-function accountController (req: express.Request, res: express.Response, next: express.NextFunction) {
+function accountController (req: express.Request, res: express.Response) {
   const account: AccountModel = res.locals.account
 
   return activityPubResponse(activityPubContextify(account.toActivityPubObject()), res)
 }
 
-async function accountFollowersController (req: express.Request, res: express.Response, next: express.NextFunction) {
+async function accountFollowersController (req: express.Request, res: express.Response) {
   const account: AccountModel = res.locals.account
   const activityPubResult = await actorFollowers(req, account.Actor)
 
   return activityPubResponse(activityPubContextify(activityPubResult), res)
 }
 
-async function accountFollowingController (req: express.Request, res: express.Response, next: express.NextFunction) {
+async function accountFollowingController (req: express.Request, res: express.Response) {
   const account: AccountModel = res.locals.account
   const activityPubResult = await actorFollowing(req, account.Actor)
 
   return activityPubResponse(activityPubContextify(activityPubResult), res)
 }
 
+async function accountPlaylistsController (req: express.Request, res: express.Response) {
+  const account: AccountModel = res.locals.account
+  const activityPubResult = await actorPlaylists(req, account)
+
+  return activityPubResponse(activityPubContextify(activityPubResult), res)
+}
+
 function getAccountVideoRate (rateType: VideoRateType) {
   return (req: express.Request, res: express.Response) => {
     const accountVideoRate: AccountVideoRateModel = res.locals.accountVideoRate
@@ -293,6 +317,23 @@ async function videoRedundancyController (req: express.Request, res: express.Res
   return activityPubResponse(activityPubContextify(object), res)
 }
 
+async function videoPlaylistController (req: express.Request, res: express.Response) {
+  const playlist: VideoPlaylistModel = res.locals.videoPlaylist
+
+  const json = await playlist.toActivityPubObject()
+  const audience = getAudience(playlist.OwnerAccount.Actor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
+  const object = audiencify(json, audience)
+
+  return activityPubResponse(activityPubContextify(object), res)
+}
+
+async function videoPlaylistElementController (req: express.Request, res: express.Response) {
+  const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement
+
+  const json = videoPlaylistElement.toActivityPubObject()
+  return activityPubResponse(activityPubContextify(json), res)
+}
+
 // ---------------------------------------------------------------------------
 
 async function actorFollowing (req: express.Request, actor: ActorModel) {
@@ -305,7 +346,15 @@ async function actorFollowing (req: express.Request, actor: ActorModel) {
 
 async function actorFollowers (req: express.Request, actor: ActorModel) {
   const handler = (start: number, count: number) => {
-    return ActorFollowModel.listAcceptedFollowerUrlsForApi([ actor.id ], undefined, start, count)
+    return ActorFollowModel.listAcceptedFollowerUrlsForAP([ actor.id ], undefined, start, count)
+  }
+
+  return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page)
+}
+
+async function actorPlaylists (req: express.Request, account: AccountModel) {
+  const handler = (start: number, count: number) => {
+    return VideoPlaylistModel.listUrlsOfForAP(account.id, start, count)
   }
 
   return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page)
index bd0e4fe9d471ba6966aaf465e66f28841336f30f..e060affb2aa7af292572f74adc5dff9863280625 100644 (file)
@@ -32,7 +32,7 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function outboxController (req: express.Request, res: express.Response, next: express.NextFunction) {
+async function outboxController (req: express.Request, res: express.Response) {
   const accountOrVideoChannel: AccountModel | VideoChannelModel = res.locals.account || res.locals.videoChannel
   const actor = accountOrVideoChannel.Actor
   const actorOutboxUrl = actor.url + '/outbox'
index 8c02372030c53961cedc96ee23c866542bd2926e..03c83109233383af1f5c3d2f6835184e4dd7fac8 100644 (file)
@@ -1,21 +1,23 @@
 import * as express from 'express'
-import { getFormattedObjects } from '../../helpers/utils'
+import { getFormattedObjects, getServerActor } from '../../helpers/utils'
 import {
   asyncMiddleware,
   commonVideosFiltersValidator,
-  listVideoAccountChannelsValidator,
   optionalAuthenticate,
   paginationValidator,
   setDefaultPagination,
-  setDefaultSort
+  setDefaultSort,
+  videoPlaylistsSortValidator
 } from '../../middlewares'
-import { accountsNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators'
+import { accountNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators'
 import { AccountModel } from '../../models/account/account'
 import { VideoModel } from '../../models/video/video'
 import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
 import { VideoChannelModel } from '../../models/video/video-channel'
 import { JobQueue } from '../../lib/job-queue'
 import { logger } from '../../helpers/logger'
+import { VideoPlaylistModel } from '../../models/video/video-playlist'
+import { UserModel } from '../../models/account/user'
 
 const accountsRouter = express.Router()
 
@@ -28,12 +30,12 @@ accountsRouter.get('/',
 )
 
 accountsRouter.get('/:accountName',
-  asyncMiddleware(accountsNameWithHostGetValidator),
+  asyncMiddleware(accountNameWithHostGetValidator),
   getAccount
 )
 
 accountsRouter.get('/:accountName/videos',
-  asyncMiddleware(accountsNameWithHostGetValidator),
+  asyncMiddleware(accountNameWithHostGetValidator),
   paginationValidator,
   videosSortValidator,
   setDefaultSort,
@@ -44,8 +46,18 @@ accountsRouter.get('/:accountName/videos',
 )
 
 accountsRouter.get('/:accountName/video-channels',
-  asyncMiddleware(listVideoAccountChannelsValidator),
-  asyncMiddleware(listVideoAccountChannels)
+  asyncMiddleware(accountNameWithHostGetValidator),
+  asyncMiddleware(listAccountChannels)
+)
+
+accountsRouter.get('/:accountName/video-playlists',
+  optionalAuthenticate,
+  asyncMiddleware(accountNameWithHostGetValidator),
+  paginationValidator,
+  videoPlaylistsSortValidator,
+  setDefaultSort,
+  setDefaultPagination,
+  asyncMiddleware(listAccountPlaylists)
 )
 
 // ---------------------------------------------------------------------------
@@ -56,7 +68,7 @@ export {
 
 // ---------------------------------------------------------------------------
 
-function getAccount (req: express.Request, res: express.Response, next: express.NextFunction) {
+function getAccount (req: express.Request, res: express.Response) {
   const account: AccountModel = res.locals.account
 
   if (account.isOutdated()) {
@@ -67,19 +79,40 @@ function getAccount (req: express.Request, res: express.Response, next: express.
   return res.json(account.toFormattedJSON())
 }
 
-async function listAccounts (req: express.Request, res: express.Response, next: express.NextFunction) {
+async function listAccounts (req: express.Request, res: express.Response) {
   const resultList = await AccountModel.listForApi(req.query.start, req.query.count, req.query.sort)
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
 }
 
-async function listVideoAccountChannels (req: express.Request, res: express.Response, next: express.NextFunction) {
+async function listAccountChannels (req: express.Request, res: express.Response) {
   const resultList = await VideoChannelModel.listByAccount(res.locals.account.id)
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
 }
 
-async function listAccountVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
+async function listAccountPlaylists (req: express.Request, res: express.Response) {
+  const serverActor = await getServerActor()
+
+  // Allow users to see their private/unlisted video playlists
+  let privateAndUnlisted = false
+  if (res.locals.oauth && (res.locals.oauth.token.User as UserModel).Account.id === res.locals.account.id) {
+    privateAndUnlisted = true
+  }
+
+  const resultList = await VideoPlaylistModel.listForApi({
+    followerActorId: serverActor.id,
+    start: req.query.start,
+    count: req.query.count,
+    sort: req.query.sort,
+    accountId: res.locals.account.id,
+    privateAndUnlisted
+  })
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+async function listAccountVideos (req: express.Request, res: express.Response) {
   const account: AccountModel = res.locals.account
   const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined
 
index 8a58b54662aef26cdf2529cebb010b7a029102dd..ed4b33deaace637a0957158c11e8360c9ec0af92 100644 (file)
@@ -11,6 +11,7 @@ import { videoChannelRouter } from './video-channel'
 import * as cors from 'cors'
 import { searchRouter } from './search'
 import { overviewsRouter } from './overviews'
+import { videoPlaylistRouter } from './video-playlist'
 
 const apiRouter = express.Router()
 
@@ -26,6 +27,7 @@ apiRouter.use('/config', configRouter)
 apiRouter.use('/users', usersRouter)
 apiRouter.use('/accounts', accountsRouter)
 apiRouter.use('/video-channels', videoChannelRouter)
+apiRouter.use('/video-playlists', videoPlaylistRouter)
 apiRouter.use('/videos', videosRouter)
 apiRouter.use('/jobs', jobsRouter)
 apiRouter.use('/search', searchRouter)
index db7602139540d85a99963e91f9754c3f489cf167..534cc8d7b19f67341eaf17903dae05b1492c6118 100644 (file)
@@ -12,7 +12,8 @@ import {
   videoChannelsAddValidator,
   videoChannelsRemoveValidator,
   videoChannelsSortValidator,
-  videoChannelsUpdateValidator
+  videoChannelsUpdateValidator,
+  videoPlaylistsSortValidator
 } from '../../middlewares'
 import { VideoChannelModel } from '../../models/video/video-channel'
 import { videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators'
@@ -31,6 +32,7 @@ import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '..
 import { resetSequelizeInstance } from '../../helpers/database-utils'
 import { UserModel } from '../../models/account/user'
 import { JobQueue } from '../../lib/job-queue'
+import { VideoPlaylistModel } from '../../models/video/video-playlist'
 
 const auditLogger = auditLoggerFactory('channels')
 const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
@@ -77,6 +79,15 @@ videoChannelRouter.get('/:nameWithHost',
   asyncMiddleware(getVideoChannel)
 )
 
+videoChannelRouter.get('/:nameWithHost/video-playlists',
+  asyncMiddleware(videoChannelsNameWithHostValidator),
+  paginationValidator,
+  videoPlaylistsSortValidator,
+  setDefaultSort,
+  setDefaultPagination,
+  asyncMiddleware(listVideoChannelPlaylists)
+)
+
 videoChannelRouter.get('/:nameWithHost/videos',
   asyncMiddleware(videoChannelsNameWithHostValidator),
   paginationValidator,
@@ -206,6 +217,20 @@ async function getVideoChannel (req: express.Request, res: express.Response, nex
   return res.json(videoChannelWithVideos.toFormattedJSON())
 }
 
+async function listVideoChannelPlaylists (req: express.Request, res: express.Response) {
+  const serverActor = await getServerActor()
+
+  const resultList = await VideoPlaylistModel.listForApi({
+    followerActorId: serverActor.id,
+    start: req.query.start,
+    count: req.query.count,
+    sort: req.query.sort,
+    videoChannelId: res.locals.videoChannel.id
+  })
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
 async function listVideoChannelVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
   const videoChannelInstance: VideoChannelModel = res.locals.videoChannel
   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 (file)
index 0000000..709c58b
--- /dev/null
@@ -0,0 +1,415 @@
+import * as express from 'express'
+import { getFormattedObjects, getServerActor } from '../../helpers/utils'
+import {
+  asyncMiddleware,
+  asyncRetryTransactionMiddleware,
+  authenticate,
+  commonVideosFiltersValidator,
+  paginationValidator,
+  setDefaultPagination,
+  setDefaultSort
+} from '../../middlewares'
+import { VideoChannelModel } from '../../models/video/video-channel'
+import { videoPlaylistsSortValidator } from '../../middlewares/validators'
+import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
+import { CONFIG, MIMETYPES, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
+import { logger } from '../../helpers/logger'
+import { resetSequelizeInstance } from '../../helpers/database-utils'
+import { VideoPlaylistModel } from '../../models/video/video-playlist'
+import {
+  videoPlaylistsAddValidator,
+  videoPlaylistsAddVideoValidator,
+  videoPlaylistsDeleteValidator,
+  videoPlaylistsGetValidator,
+  videoPlaylistsReorderVideosValidator,
+  videoPlaylistsUpdateOrRemoveVideoValidator,
+  videoPlaylistsUpdateValidator
+} from '../../middlewares/validators/videos/video-playlists'
+import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
+import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
+import { processImage } from '../../helpers/image-utils'
+import { join } from 'path'
+import { UserModel } from '../../models/account/user'
+import {
+  getVideoPlaylistActivityPubUrl,
+  getVideoPlaylistElementActivityPubUrl,
+  sendCreateVideoPlaylist,
+  sendDeleteVideoPlaylist,
+  sendUpdateVideoPlaylist
+} from '../../lib/activitypub'
+import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model'
+import { VideoModel } from '../../models/video/video'
+import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
+import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
+import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
+import { copy, pathExists } from 'fs-extra'
+
+const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
+
+const videoPlaylistRouter = express.Router()
+
+videoPlaylistRouter.get('/',
+  paginationValidator,
+  videoPlaylistsSortValidator,
+  setDefaultSort,
+  setDefaultPagination,
+  asyncMiddleware(listVideoPlaylists)
+)
+
+videoPlaylistRouter.get('/:playlistId',
+  asyncMiddleware(videoPlaylistsGetValidator),
+  getVideoPlaylist
+)
+
+videoPlaylistRouter.post('/',
+  authenticate,
+  reqThumbnailFile,
+  asyncMiddleware(videoPlaylistsAddValidator),
+  asyncRetryTransactionMiddleware(addVideoPlaylist)
+)
+
+videoPlaylistRouter.put('/:playlistId',
+  authenticate,
+  reqThumbnailFile,
+  asyncMiddleware(videoPlaylistsUpdateValidator),
+  asyncRetryTransactionMiddleware(updateVideoPlaylist)
+)
+
+videoPlaylistRouter.delete('/:playlistId',
+  authenticate,
+  asyncMiddleware(videoPlaylistsDeleteValidator),
+  asyncRetryTransactionMiddleware(removeVideoPlaylist)
+)
+
+videoPlaylistRouter.get('/:playlistId/videos',
+  asyncMiddleware(videoPlaylistsGetValidator),
+  paginationValidator,
+  setDefaultPagination,
+  commonVideosFiltersValidator,
+  asyncMiddleware(getVideoPlaylistVideos)
+)
+
+videoPlaylistRouter.post('/:playlistId/videos',
+  authenticate,
+  asyncMiddleware(videoPlaylistsAddVideoValidator),
+  asyncRetryTransactionMiddleware(addVideoInPlaylist)
+)
+
+videoPlaylistRouter.put('/:playlistId/videos',
+  authenticate,
+  asyncMiddleware(videoPlaylistsReorderVideosValidator),
+  asyncRetryTransactionMiddleware(reorderVideosPlaylist)
+)
+
+videoPlaylistRouter.put('/:playlistId/videos/:videoId',
+  authenticate,
+  asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
+  asyncRetryTransactionMiddleware(updateVideoPlaylistElement)
+)
+
+videoPlaylistRouter.delete('/:playlistId/videos/:videoId',
+  authenticate,
+  asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
+  asyncRetryTransactionMiddleware(removeVideoFromPlaylist)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  videoPlaylistRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function listVideoPlaylists (req: express.Request, res: express.Response) {
+  const serverActor = await getServerActor()
+  const resultList = await VideoPlaylistModel.listForApi({
+    followerActorId: serverActor.id,
+    start: req.query.start,
+    count: req.query.count,
+    sort: req.query.sort
+  })
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+function getVideoPlaylist (req: express.Request, res: express.Response) {
+  const videoPlaylist = res.locals.videoPlaylist as VideoPlaylistModel
+
+  return res.json(videoPlaylist.toFormattedJSON())
+}
+
+async function addVideoPlaylist (req: express.Request, res: express.Response) {
+  const videoPlaylistInfo: VideoPlaylistCreate = req.body
+  const user: UserModel = res.locals.oauth.token.User
+
+  const videoPlaylist = new VideoPlaylistModel({
+    name: videoPlaylistInfo.displayName,
+    description: videoPlaylistInfo.description,
+    privacy: videoPlaylistInfo.privacy || VideoPlaylistPrivacy.PRIVATE,
+    ownerAccountId: user.Account.id
+  })
+
+  videoPlaylist.url = getVideoPlaylistActivityPubUrl(videoPlaylist) // We use the UUID, so set the URL after building the object
+
+  if (videoPlaylistInfo.videoChannelId !== undefined) {
+    const videoChannel = res.locals.videoChannel as VideoChannelModel
+
+    videoPlaylist.videoChannelId = videoChannel.id
+    videoPlaylist.VideoChannel = videoChannel
+  }
+
+  const thumbnailField = req.files['thumbnailfile']
+  if (thumbnailField) {
+    const thumbnailPhysicalFile = thumbnailField[ 0 ]
+    await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()), THUMBNAILS_SIZE)
+  }
+
+  const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => {
+    const videoPlaylistCreated = await videoPlaylist.save({ transaction: t })
+
+    await sendCreateVideoPlaylist(videoPlaylistCreated, t)
+
+    return videoPlaylistCreated
+  })
+
+  logger.info('Video playlist with uuid %s created.', videoPlaylist.uuid)
+
+  return res.json({
+    videoPlaylist: {
+      id: videoPlaylistCreated.id,
+      uuid: videoPlaylistCreated.uuid
+    }
+  }).end()
+}
+
+async function updateVideoPlaylist (req: express.Request, res: express.Response) {
+  const videoPlaylistInstance = res.locals.videoPlaylist as VideoPlaylistModel
+  const videoPlaylistFieldsSave = videoPlaylistInstance.toJSON()
+  const videoPlaylistInfoToUpdate = req.body as VideoPlaylistUpdate
+  const wasPrivatePlaylist = videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE
+
+  const thumbnailField = req.files['thumbnailfile']
+  if (thumbnailField) {
+    const thumbnailPhysicalFile = thumbnailField[ 0 ]
+    await processImage(
+      thumbnailPhysicalFile,
+      join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylistInstance.getThumbnailName()),
+      THUMBNAILS_SIZE
+    )
+  }
+
+  try {
+    await sequelizeTypescript.transaction(async t => {
+      const sequelizeOptions = {
+        transaction: t
+      }
+
+      if (videoPlaylistInfoToUpdate.videoChannelId !== undefined) {
+        if (videoPlaylistInfoToUpdate.videoChannelId === null) {
+          videoPlaylistInstance.videoChannelId = null
+        } else {
+          const videoChannel = res.locals.videoChannel as VideoChannelModel
+
+          videoPlaylistInstance.videoChannelId = videoChannel.id
+        }
+      }
+
+      if (videoPlaylistInfoToUpdate.displayName !== undefined) videoPlaylistInstance.name = videoPlaylistInfoToUpdate.displayName
+      if (videoPlaylistInfoToUpdate.description !== undefined) videoPlaylistInstance.description = videoPlaylistInfoToUpdate.description
+
+      if (videoPlaylistInfoToUpdate.privacy !== undefined) {
+        videoPlaylistInstance.privacy = parseInt(videoPlaylistInfoToUpdate.privacy.toString(), 10)
+      }
+
+      const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions)
+
+      const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE
+
+      if (isNewPlaylist) {
+        await sendCreateVideoPlaylist(playlistUpdated, t)
+      } else {
+        await sendUpdateVideoPlaylist(playlistUpdated, t)
+      }
+
+      logger.info('Video playlist %s updated.', videoPlaylistInstance.uuid)
+
+      return playlistUpdated
+    })
+  } catch (err) {
+    logger.debug('Cannot update the video playlist.', { err })
+
+    // Force fields we want to update
+    // If the transaction is retried, sequelize will think the object has not changed
+    // So it will skip the SQL request, even if the last one was ROLLBACKed!
+    resetSequelizeInstance(videoPlaylistInstance, videoPlaylistFieldsSave)
+
+    throw err
+  }
+
+  return res.type('json').status(204).end()
+}
+
+async function removeVideoPlaylist (req: express.Request, res: express.Response) {
+  const videoPlaylistInstance: VideoPlaylistModel = res.locals.videoPlaylist
+
+  await sequelizeTypescript.transaction(async t => {
+    await videoPlaylistInstance.destroy({ transaction: t })
+
+    await sendDeleteVideoPlaylist(videoPlaylistInstance, t)
+
+    logger.info('Video playlist %s deleted.', videoPlaylistInstance.uuid)
+  })
+
+  return res.type('json').status(204).end()
+}
+
+async function addVideoInPlaylist (req: express.Request, res: express.Response) {
+  const body: VideoPlaylistElementCreate = req.body
+  const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
+  const video: VideoModel = res.locals.video
+
+  const playlistElement: VideoPlaylistElementModel = await sequelizeTypescript.transaction(async t => {
+    const position = await VideoPlaylistElementModel.getNextPositionOf(videoPlaylist.id, t)
+
+    const playlistElement = await VideoPlaylistElementModel.create({
+      url: getVideoPlaylistElementActivityPubUrl(videoPlaylist, video),
+      position,
+      startTimestamp: body.startTimestamp || null,
+      stopTimestamp: body.stopTimestamp || null,
+      videoPlaylistId: videoPlaylist.id,
+      videoId: video.id
+    }, { transaction: t })
+
+    // If the user did not set a thumbnail, automatically take the video thumbnail
+    if (playlistElement.position === 1) {
+      const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName())
+
+      if (await pathExists(playlistThumbnailPath) === false) {
+        const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
+        await copy(videoThumbnailPath, playlistThumbnailPath)
+      }
+    }
+
+    await sendUpdateVideoPlaylist(videoPlaylist, t)
+
+    return playlistElement
+  })
+
+  logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
+
+  return res.json({
+    videoPlaylistElement: {
+      id: playlistElement.id
+    }
+  }).end()
+}
+
+async function updateVideoPlaylistElement (req: express.Request, res: express.Response) {
+  const body: VideoPlaylistElementUpdate = req.body
+  const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
+  const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement
+
+  const playlistElement: VideoPlaylistElementModel = await sequelizeTypescript.transaction(async t => {
+    if (body.startTimestamp !== undefined) videoPlaylistElement.startTimestamp = body.startTimestamp
+    if (body.stopTimestamp !== undefined) videoPlaylistElement.stopTimestamp = body.stopTimestamp
+
+    const element = await videoPlaylistElement.save({ transaction: t })
+
+    await sendUpdateVideoPlaylist(videoPlaylist, t)
+
+    return element
+  })
+
+  logger.info('Element of position %d of playlist %s updated.', playlistElement.position, videoPlaylist.uuid)
+
+  return res.type('json').status(204).end()
+}
+
+async function removeVideoFromPlaylist (req: express.Request, res: express.Response) {
+  const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement
+  const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
+  const positionToDelete = videoPlaylistElement.position
+
+  await sequelizeTypescript.transaction(async t => {
+    await videoPlaylistElement.destroy({ transaction: t })
+
+    // Decrease position of the next elements
+    await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t)
+
+    await sendUpdateVideoPlaylist(videoPlaylist, t)
+
+    logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid)
+  })
+
+  return res.type('json').status(204).end()
+}
+
+async function reorderVideosPlaylist (req: express.Request, res: express.Response) {
+  const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
+
+  const start: number = req.body.startPosition
+  const insertAfter: number = req.body.insertAfter
+  const reorderLength: number = req.body.reorderLength || 1
+
+  if (start === insertAfter) {
+    return res.status(204).end()
+  }
+
+  // Example: if we reorder position 2 and insert after position 5 (so at position 6): # 1 2 3 4 5 6 7 8 9
+  //  * increase position when position > 5 # 1 2 3 4 5 7 8 9 10
+  //  * update position 2 -> position 6 # 1 3 4 5 6 7 8 9 10
+  //  * decrease position when position position > 2 # 1 2 3 4 5 6 7 8 9
+  await sequelizeTypescript.transaction(async t => {
+    const newPosition = insertAfter + 1
+
+    // Add space after the position when we want to insert our reordered elements (increase)
+    await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, newPosition, null, reorderLength, t)
+
+    let oldPosition = start
+
+    // We incremented the position of the elements we want to reorder
+    if (start >= newPosition) oldPosition += reorderLength
+
+    const endOldPosition = oldPosition + reorderLength - 1
+    // Insert our reordered elements in their place (update)
+    await VideoPlaylistElementModel.reassignPositionOf(videoPlaylist.id, oldPosition, endOldPosition, newPosition, t)
+
+    // Decrease positions of elements after the old position of our ordered elements (decrease)
+    await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t)
+
+    await sendUpdateVideoPlaylist(videoPlaylist, t)
+  })
+
+  logger.info(
+    'Reordered playlist %s (inserted after %d elements %d - %d).',
+    videoPlaylist.uuid, insertAfter, start, start + reorderLength - 1
+  )
+
+  return res.type('json').status(204).end()
+}
+
+async function getVideoPlaylistVideos (req: express.Request, res: express.Response) {
+  const videoPlaylistInstance: VideoPlaylistModel = res.locals.videoPlaylist
+  const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined
+
+  const resultList = await VideoModel.listForApi({
+    followerActorId,
+    start: req.query.start,
+    count: req.query.count,
+    sort: 'VideoPlaylistElements.position',
+    includeLocalVideos: true,
+    categoryOneOf: req.query.categoryOneOf,
+    licenceOneOf: req.query.licenceOneOf,
+    languageOneOf: req.query.languageOneOf,
+    tagsOneOf: req.query.tagsOneOf,
+    tagsAllOf: req.query.tagsAllOf,
+    filter: req.query.filter,
+    nsfw: buildNSFWFilter(res, req.query.nsfw),
+    withFiles: false,
+    videoPlaylistId: videoPlaylistInstance.id,
+    user: res.locals.oauth ? res.locals.oauth.token.User : undefined
+  })
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
index 43b0516e79a8eed2af326e0294bb700509f710bb..b01296200c96050a05a441d2dc7e88f31a377626 100644 (file)
@@ -1,5 +1,5 @@
 import * as express from 'express'
-import { VideoBlacklist, UserRight, VideoBlacklistCreate } from '../../../../shared'
+import { UserRight, VideoBlacklist, VideoBlacklistCreate } from '../../../../shared'
 import { logger } from '../../../helpers/logger'
 import { getFormattedObjects } from '../../../helpers/utils'
 import {
@@ -18,7 +18,7 @@ import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
 import { sequelizeTypescript } from '../../../initializers'
 import { Notifier } from '../../../lib/notifier'
 import { VideoModel } from '../../../models/video/video'
-import { sendCreateVideo, sendDeleteVideo, sendUpdateVideo } from '../../../lib/activitypub/send'
+import { sendDeleteVideo } from '../../../lib/activitypub/send'
 import { federateVideoIfNeeded } from '../../../lib/activitypub'
 
 const blacklistRouter = express.Router()
index 352d0b19a8bb096c7e26d800fe5be499c4044573..680c3c37f4b3b07b0d1e34779ff3a085d3cadb39 100644 (file)
@@ -1,7 +1,7 @@
 import * as express from 'express'
 import { CONFIG, EMBED_SIZE, PREVIEWS_SIZE } from '../initializers'
 import { asyncMiddleware, oembedValidator } from '../middlewares'
-import { accountsNameWithHostGetValidator } from '../middlewares/validators'
+import { accountNameWithHostGetValidator } from '../middlewares/validators'
 import { VideoModel } from '../models/video/video'
 
 const servicesRouter = express.Router()
@@ -11,7 +11,7 @@ servicesRouter.use('/oembed',
   generateOEmbed
 )
 servicesRouter.use('/redirect/accounts/:accountName',
-  asyncMiddleware(accountsNameWithHostGetValidator),
+  asyncMiddleware(accountNameWithHostGetValidator),
   redirectToAccountUrl
 )
 
index e850efe138eb53a4097e4a1a4cd742f6a1f60c57..31c6187d108d9ea60fbc4e2607f51fffc54a557f 100644 (file)
@@ -28,6 +28,9 @@ function activityPubContextify <T> (data: T) {
         state: 'sc:Number',
         size: 'sc:Number',
         fps: 'sc:Number',
+        startTimestamp: 'sc:Number',
+        stopTimestamp: 'sc:Number',
+        position: 'sc:Number',
         commentsEnabled: 'sc:Boolean',
         downloadEnabled: 'sc:Boolean',
         waitTranscoding: 'sc:Boolean',
@@ -46,6 +49,10 @@ function activityPubContextify <T> (data: T) {
           '@id': 'as:dislikes',
           '@type': '@id'
         },
+        playlists: {
+          '@id': 'pt:playlists',
+          '@type': '@id'
+        },
         shares: {
           '@id': 'as:shares',
           '@type': '@id'
@@ -67,7 +74,7 @@ async function activityPubCollectionPagination (baseUrl: string, handler: Activi
 
     return {
       id: baseUrl,
-      type: 'OrderedCollection',
+      type: 'OrderedCollectionPage',
       totalItems: result.total,
       first: baseUrl + '?page=1'
     }
index b24590d9d752f7a0e20666c680ad2e7babd1a92b..e0d170d9d230d175ad01769f78e11b2c65d0887e 100644 (file)
@@ -9,6 +9,7 @@ import { isViewActivityValid } from './view'
 import { exists } from '../misc'
 import { isCacheFileObjectValid } from './cache-file'
 import { isFlagActivityValid } from './flag'
+import { isPlaylistObjectValid } from './playlist'
 
 function isRootActivityValid (activity: any) {
   return Array.isArray(activity['@context']) && (
@@ -78,6 +79,7 @@ function checkCreateActivity (activity: any) {
       isViewActivityValid(activity.object) ||
       isDislikeActivityValid(activity.object) ||
       isFlagActivityValid(activity.object) ||
+      isPlaylistObjectValid(activity.object) ||
 
       isCacheFileObjectValid(activity.object) ||
       sanitizeAndCheckVideoCommentObject(activity.object) ||
@@ -89,6 +91,7 @@ function checkUpdateActivity (activity: any) {
   return isBaseActivityValid(activity, 'Update') &&
     (
       isCacheFileObjectValid(activity.object) ||
+      isPlaylistObjectValid(activity.object) ||
       sanitizeAndCheckVideoTorrentObject(activity.object) ||
       sanitizeAndCheckActorObject(activity.object)
     )
diff --git a/server/helpers/custom-validators/activitypub/playlist.ts b/server/helpers/custom-validators/activitypub/playlist.ts
new file mode 100644 (file)
index 0000000..ecdc797
--- /dev/null
@@ -0,0 +1,25 @@
+import { exists } from '../misc'
+import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
+import * as validator from 'validator'
+import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object'
+import { isActivityPubUrlValid } from './misc'
+
+function isPlaylistObjectValid (object: PlaylistObject) {
+  return exists(object) &&
+    object.type === 'Playlist' &&
+    validator.isInt(object.totalItems + '')
+}
+
+function isPlaylistElementObjectValid (object: PlaylistElementObject) {
+  return exists(object) &&
+    object.type === 'PlaylistElement' &&
+    validator.isInt(object.position + '') &&
+    isActivityPubUrlValid(object.url)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  isPlaylistObjectValid,
+  isPlaylistElementObjectValid
+}
diff --git a/server/helpers/custom-validators/video-playlists.ts b/server/helpers/custom-validators/video-playlists.ts
new file mode 100644 (file)
index 0000000..0f5af4e
--- /dev/null
@@ -0,0 +1,44 @@
+import { exists } from './misc'
+import * as validator from 'validator'
+import { CONSTRAINTS_FIELDS, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers'
+import * as express from 'express'
+import { VideoPlaylistModel } from '../../models/video/video-playlist'
+import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
+
+const PLAYLISTS_CONSTRAINT_FIELDS = CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS
+
+function isVideoPlaylistNameValid (value: any) {
+  return exists(value) && validator.isLength(value, PLAYLISTS_CONSTRAINT_FIELDS.NAME)
+}
+
+function isVideoPlaylistDescriptionValid (value: any) {
+  return value === null || (exists(value) && validator.isLength(value, PLAYLISTS_CONSTRAINT_FIELDS.DESCRIPTION))
+}
+
+function isVideoPlaylistPrivacyValid (value: number) {
+  return validator.isInt(value + '') && VIDEO_PLAYLIST_PRIVACIES[ value ] !== undefined
+}
+
+async function isVideoPlaylistExist (id: number | string, res: express.Response) {
+  const videoPlaylist = await VideoPlaylistModel.load(id, undefined)
+
+  if (!videoPlaylist) {
+    res.status(404)
+       .json({ error: 'Video playlist not found' })
+       .end()
+
+    return false
+  }
+
+  res.locals.videoPlaylist = videoPlaylist
+  return true
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  isVideoPlaylistExist,
+  isVideoPlaylistNameValid,
+  isVideoPlaylistDescriptionValid,
+  isVideoPlaylistPrivacyValid
+}
index dd04aa5f65c4346aac6fa04d44efd2d72bb9d37b..d00d24c4c9831a20ad74ca6b13c94d24e133e486 100644 (file)
@@ -165,7 +165,7 @@ function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: Use
   return true
 }
 
-async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') {
+async function isVideoExist (id: number | string, res: Response, fetchType: VideoFetchType = 'all') {
   const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
 
   const video = await fetchVideo(id, fetchType, userId)
index 0d9a6a512a3a2f2421a39ee4feadd793dcba247a..154a9cffe2330215a071808ec71aafce8269bd58 100644 (file)
@@ -10,6 +10,7 @@ import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
 import { invert } from 'lodash'
 import { CronRepeatOptions, EveryRepeatOptions } from 'bull'
 import * as bytes from 'bytes'
+import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model'
 
 // Use a variable to reload the configuration if we need
 let config: IConfig = require('config')
@@ -52,7 +53,9 @@ const SORTABLE_COLUMNS = {
   ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
   SERVERS_BLOCKLIST: [ 'createdAt' ],
 
-  USER_NOTIFICATIONS: [ 'createdAt' ]
+  USER_NOTIFICATIONS: [ 'createdAt' ],
+
+  VIDEO_PLAYLISTS: [ 'createdAt' ]
 }
 
 const OAUTH_LIFETIME = {
@@ -386,6 +389,17 @@ let CONSTRAINTS_FIELDS = {
     FILE_SIZE: { min: 10 },
     URL: { min: 3, max: 2000 } // Length
   },
+  VIDEO_PLAYLISTS: {
+    NAME: { min: 1, max: 120 }, // Length
+    DESCRIPTION: { min: 3, max: 1000 }, // Length
+    URL: { min: 3, max: 2000 }, // Length
+    IMAGE: {
+      EXTNAME: [ '.jpg', '.jpeg' ],
+      FILE_SIZE: {
+        max: 2 * 1024 * 1024 // 2MB
+      }
+    }
+  },
   ACTORS: {
     PUBLIC_KEY: { min: 10, max: 5000 }, // Length
     PRIVATE_KEY: { min: 10, max: 5000 }, // Length
@@ -502,6 +516,12 @@ const VIDEO_ABUSE_STATES = {
   [VideoAbuseState.ACCEPTED]: 'Accepted'
 }
 
+const VIDEO_PLAYLIST_PRIVACIES = {
+  [VideoPlaylistPrivacy.PUBLIC]: 'Public',
+  [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
+  [VideoPlaylistPrivacy.PRIVATE]: 'Private'
+}
+
 const MIMETYPES = {
   VIDEO: {
     MIMETYPE_EXT: buildVideoMimetypeExt(),
@@ -786,6 +806,7 @@ export {
   VIDEO_IMPORT_STATES,
   VIDEO_VIEW_LIFETIME,
   CONTACT_FORM_LIFETIME,
+  VIDEO_PLAYLIST_PRIVACIES,
   buildLanguages
 }
 
index fe296142d0354a0f33e993a8f238757fae4d81c7..541ebbecf91be9ca3b6f3246e7b060b13967b703 100644 (file)
@@ -34,6 +34,8 @@ import { ServerBlocklistModel } from '../models/server/server-blocklist'
 import { UserNotificationModel } from '../models/account/user-notification'
 import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
 import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
+import { VideoPlaylistModel } from '../models/video/video-playlist'
+import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
 
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -101,7 +103,9 @@ async function initDatabaseModels (silent: boolean) {
     ServerBlocklistModel,
     UserNotificationModel,
     UserNotificationSettingModel,
-    VideoStreamingPlaylistModel
+    VideoStreamingPlaylistModel,
+    VideoPlaylistModel,
+    VideoPlaylistElementModel
   ])
 
   // Check extensions exist in the database
index a3f379b76938a29e6a587488611cbf468117346e..f77df8b78d5c92820c0ce041fdcebb72cd7ce2d6 100644 (file)
@@ -44,6 +44,7 @@ async function getOrCreateActorAndServerAndModel (
 ) {
   const actorUrl = getAPId(activityActor)
   let created = false
+  let accountPlaylistsUrl: string
 
   let actor = await fetchActorByUrl(actorUrl, fetchType)
   // Orphan actor (not associated to an account of channel) so recreate it
@@ -70,7 +71,8 @@ async function getOrCreateActorAndServerAndModel (
 
       try {
         // Don't recurse another time
-        ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', false)
+        const recurseIfNeeded = false
+        ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', recurseIfNeeded)
       } catch (err) {
         logger.error('Cannot get or create account attributed to video channel ' + actor.url)
         throw new Error(err)
@@ -79,6 +81,7 @@ async function getOrCreateActorAndServerAndModel (
 
     actor = await retryTransactionWrapper(saveActorAndServerAndModelIfNotExist, result, ownerActor)
     created = true
+    accountPlaylistsUrl = result.playlists
   }
 
   if (actor.Account) actor.Account.Actor = actor
@@ -92,6 +95,12 @@ async function getOrCreateActorAndServerAndModel (
     await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
   }
 
+  // We created a new account: fetch the playlists
+  if (created === true && actor.Account && accountPlaylistsUrl) {
+    const payload = { uri: accountPlaylistsUrl, accountId: actor.Account.id, type: 'account-playlists' as 'account-playlists' }
+    await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
+  }
+
   return actorRefreshed
 }
 
@@ -342,6 +351,7 @@ type FetchRemoteActorResult = {
   name: string
   summary: string
   support?: string
+  playlists?: string
   avatarName?: string
   attributedTo: ActivityPubAttributedTo[]
 }
@@ -398,6 +408,7 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
       avatarName,
       summary: actorJSON.summary,
       support: actorJSON.support,
+      playlists: actorJSON.playlists,
       attributedTo: actorJSON.attributedTo
     }
   }
index 9a40414bba221e7537b6cd7ba4065545d4d57d6d..59700313568e5ecda12346b5a62b172b7b072155 100644 (file)
@@ -1,4 +1,4 @@
-import { ActivityPlaylistUrlObject, ActivityVideoUrlObject, CacheFileObject } from '../../../shared/index'
+import { CacheFileObject } from '../../../shared/index'
 import { VideoModel } from '../../models/video/video'
 import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
 import { Transaction } from 'sequelize'
index 1b9b14c2e55177657a655e1de9a81143ebcec39f..2675524c63037140e6aab3a00de023751921bb74 100644 (file)
@@ -4,7 +4,7 @@ import { logger } from '../../helpers/logger'
 import * as Bluebird from 'bluebird'
 import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
 
-async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) {
+async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => (Promise<any> | Bluebird<any>)) {
   logger.info('Crawling ActivityPub data on %s.', uri)
 
   const options = {
diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts
new file mode 100644 (file)
index 0000000..c9b428c
--- /dev/null
@@ -0,0 +1,162 @@
+import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
+import { crawlCollectionPage } from './crawl'
+import { ACTIVITY_PUB, CONFIG, CRAWL_REQUEST_CONCURRENCY, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
+import { AccountModel } from '../../models/account/account'
+import { isArray } from '../../helpers/custom-validators/misc'
+import { getOrCreateActorAndServerAndModel } from './actor'
+import { logger } from '../../helpers/logger'
+import { VideoPlaylistModel } from '../../models/video/video-playlist'
+import { doRequest, downloadImage } from '../../helpers/requests'
+import { checkUrlsSameHost } from '../../helpers/activitypub'
+import * as Bluebird from 'bluebird'
+import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
+import { getOrCreateVideoAndAccountAndChannel } from './videos'
+import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '../../helpers/custom-validators/activitypub/playlist'
+import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
+import { VideoModel } from '../../models/video/video'
+import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
+import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
+import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
+
+function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) {
+  const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED
+
+  return {
+    name: playlistObject.name,
+    description: playlistObject.content,
+    privacy,
+    url: playlistObject.id,
+    uuid: playlistObject.uuid,
+    ownerAccountId: byAccount.id,
+    videoChannelId: null
+  }
+}
+
+function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: VideoPlaylistModel, video: VideoModel) {
+  return {
+    position: elementObject.position,
+    url: elementObject.id,
+    startTimestamp: elementObject.startTimestamp || null,
+    stopTimestamp: elementObject.stopTimestamp || null,
+    videoPlaylistId: videoPlaylist.id,
+    videoId: video.id
+  }
+}
+
+async function createAccountPlaylists (playlistUrls: string[], account: AccountModel) {
+  await Bluebird.map(playlistUrls, async playlistUrl => {
+    try {
+      const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
+      if (exists === true) return
+
+      // Fetch url
+      const { body } = await doRequest<PlaylistObject>({
+        uri: playlistUrl,
+        json: true,
+        activityPub: true
+      })
+
+      if (!isPlaylistObjectValid(body)) {
+        throw new Error(`Invalid playlist object when fetch account playlists: ${JSON.stringify(body)}`)
+      }
+
+      if (!isArray(body.to)) {
+        throw new Error('Playlist does not have an audience.')
+      }
+
+      return createOrUpdateVideoPlaylist(body, account, body.to)
+    } catch (err) {
+      logger.warn('Cannot add playlist element %s.', playlistUrl, { err })
+    }
+  }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
+}
+
+async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) {
+  const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to)
+
+  if (isArray(playlistObject.attributedTo) && playlistObject.attributedTo.length === 1) {
+    const actor = await getOrCreateActorAndServerAndModel(playlistObject.attributedTo[0])
+
+    if (actor.VideoChannel) {
+      playlistAttributes.videoChannelId = actor.VideoChannel.id
+    } else {
+      logger.warn('Attributed to of video playlist %s is not a video channel.', playlistObject.id, { playlistObject })
+    }
+  }
+
+  const [ playlist ] = await VideoPlaylistModel.upsert<VideoPlaylistModel>(playlistAttributes, { returning: true })
+
+  let accItems: string[] = []
+  await crawlCollectionPage<string>(playlistObject.id, items => {
+    accItems = accItems.concat(items)
+
+    return Promise.resolve()
+  })
+
+  // Empty playlists generally do not have a miniature, so skip it
+  if (accItems.length !== 0) {
+    try {
+      await generateThumbnailFromUrl(playlist, playlistObject.icon)
+    } catch (err) {
+      logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err })
+    }
+  }
+
+  return resetVideoPlaylistElements(accItems, playlist)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  createAccountPlaylists,
+  playlistObjectToDBAttributes,
+  playlistElementObjectToDBAttributes,
+  createOrUpdateVideoPlaylist
+}
+
+// ---------------------------------------------------------------------------
+
+async function resetVideoPlaylistElements (elementUrls: string[], playlist: VideoPlaylistModel) {
+  const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
+
+  await Bluebird.map(elementUrls, async elementUrl => {
+    try {
+      // Fetch url
+      const { body } = await doRequest<PlaylistElementObject>({
+        uri: elementUrl,
+        json: true,
+        activityPub: true
+      })
+
+      if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in video get playlist element ${elementUrl}`)
+
+      if (checkUrlsSameHost(body.id, elementUrl) !== true) {
+        throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`)
+      }
+
+      const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: { id: body.url }, fetchType: 'only-video' })
+
+      elementsToCreate.push(playlistElementObjectToDBAttributes(body, playlist, video))
+    } catch (err) {
+      logger.warn('Cannot add playlist element %s.', elementUrl, { err })
+    }
+  }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
+
+  await sequelizeTypescript.transaction(async t => {
+    await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
+
+    for (const element of elementsToCreate) {
+      await VideoPlaylistElementModel.create(element, { transaction: t })
+    }
+  })
+
+  logger.info('Reset playlist %s with %s elements.', playlist.url, elementsToCreate.length)
+
+  return undefined
+}
+
+function generateThumbnailFromUrl (playlist: VideoPlaylistModel, icon: ActivityIconObject) {
+  const thumbnailName = playlist.getThumbnailName()
+
+  return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
+}
index 5f4d793a5746db35d9ae3312616bc76afb15b20f..e882669ceea30cb6954e99ce9d286ebc2c9cf43e 100644 (file)
@@ -12,6 +12,8 @@ import { Notifier } from '../../notifier'
 import { processViewActivity } from './process-view'
 import { processDislikeActivity } from './process-dislike'
 import { processFlagActivity } from './process-flag'
+import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
+import { createOrUpdateVideoPlaylist } from '../playlist'
 
 async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
   const activityObject = activity.object
@@ -38,7 +40,11 @@ async function processCreateActivity (activity: ActivityCreate, byActor: ActorMo
   }
 
   if (activityType === 'CacheFile') {
-    return retryTransactionWrapper(processCacheFile, activity, byActor)
+    return retryTransactionWrapper(processCreateCacheFile, activity, byActor)
+  }
+
+  if (activityType === 'Playlist') {
+    return retryTransactionWrapper(processCreatePlaylist, activity, byActor)
   }
 
   logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
@@ -63,7 +69,7 @@ async function processCreateVideo (activity: ActivityCreate) {
   return video
 }
 
-async function processCacheFile (activity: ActivityCreate, byActor: ActorModel) {
+async function processCreateCacheFile (activity: ActivityCreate, byActor: ActorModel) {
   const cacheFile = activity.object as CacheFileObject
 
   const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object })
@@ -98,3 +104,12 @@ async function processCreateVideoComment (activity: ActivityCreate, byActor: Act
 
   if (created === true) Notifier.Instance.notifyOnNewComment(comment)
 }
+
+async function processCreatePlaylist (activity: ActivityCreate, byActor: ActorModel) {
+  const playlistObject = activity.object as PlaylistObject
+  const byAccount = byActor.Account
+
+  if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
+
+  await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to)
+}
index c6b42d8465eae5de78c861675e33e183017e9fd5..0b96ba3526593b731f607fab94fd25b661911dda 100644 (file)
@@ -12,6 +12,8 @@ import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-vali
 import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
 import { createOrUpdateCacheFile } from '../cache-file'
 import { forwardVideoRelatedActivity } from '../send/utils'
+import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
+import { createOrUpdateVideoPlaylist } from '../playlist'
 
 async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorModel) {
   const objectType = activity.object.type
@@ -32,6 +34,10 @@ async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorMo
     return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity)
   }
 
+  if (objectType === 'Playlist') {
+    return retryTransactionWrapper(processUpdatePlaylist, byActor, activity)
+  }
+
   return undefined
 }
 
@@ -135,3 +141,12 @@ async function processUpdateActor (actor: ActorModel, activity: ActivityUpdate)
     throw err
   }
 }
+
+async function processUpdatePlaylist (byActor: ActorModel, activity: ActivityUpdate) {
+  const playlistObject = activity.object as PlaylistObject
+  const byAccount = byActor.Account
+
+  if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
+
+  await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to)
+}
index ef20e404c4b59b802fad869ea5b8a149fd7af456..bacdb97e3d3c860f025d368d4baf6f54bb3fdb96 100644 (file)
@@ -8,6 +8,9 @@ import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unic
 import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience'
 import { logger } from '../../../helpers/logger'
 import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
+import { VideoPlaylistModel } from '../../../models/video/video-playlist'
+import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
+import { getServerActor } from '../../../helpers/utils'
 
 async function sendCreateVideo (video: VideoModel, t: Transaction) {
   if (video.privacy === VideoPrivacy.PRIVATE) return undefined
@@ -34,6 +37,25 @@ async function sendCreateCacheFile (byActor: ActorModel, video: VideoModel, file
   })
 }
 
+async function sendCreateVideoPlaylist (playlist: VideoPlaylistModel, t: Transaction) {
+  if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
+
+  logger.info('Creating job to send create video playlist of %s.', playlist.url)
+
+  const byActor = playlist.OwnerAccount.Actor
+  const audience = getAudience(byActor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
+
+  const object = await playlist.toActivityPubObject()
+  const createActivity = buildCreateActivity(playlist.url, byActor, object, audience)
+
+  const serverActor = await getServerActor()
+  const toFollowersOf = [ byActor, serverActor ]
+
+  if (playlist.VideoChannel) toFollowersOf.push(playlist.VideoChannel.Actor)
+
+  return broadcastToFollowers(createActivity, byActor, toFollowersOf, t)
+}
+
 async function sendCreateVideoComment (comment: VideoCommentModel, t: Transaction) {
   logger.info('Creating job to send comment %s.', comment.url)
 
@@ -92,6 +114,7 @@ export {
   sendCreateVideo,
   buildCreateActivity,
   sendCreateVideoComment,
+  sendCreateVideoPlaylist,
   sendCreateCacheFile
 }
 
index 18969433a8db3ba460363415f65f7924b83fa22e..016811e6069e61255e751fb1ee2d6fe1138a0eaf 100644 (file)
@@ -8,6 +8,8 @@ import { getDeleteActivityPubUrl } from '../url'
 import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
 import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
 import { logger } from '../../../helpers/logger'
+import { VideoPlaylistModel } from '../../../models/video/video-playlist'
+import { getServerActor } from '../../../helpers/utils'
 
 async function sendDeleteVideo (video: VideoModel, transaction: Transaction) {
   logger.info('Creating job to broadcast delete of video %s.', video.url)
@@ -64,12 +66,29 @@ async function sendDeleteVideoComment (videoComment: VideoCommentModel, t: Trans
   return unicastTo(activity, byActor, videoComment.Video.VideoChannel.Account.Actor.sharedInboxUrl)
 }
 
+async function sendDeleteVideoPlaylist (videoPlaylist: VideoPlaylistModel, t: Transaction) {
+  logger.info('Creating job to send delete of playlist %s.', videoPlaylist.url)
+
+  const byActor = videoPlaylist.OwnerAccount.Actor
+
+  const url = getDeleteActivityPubUrl(videoPlaylist.url)
+  const activity = buildDeleteActivity(url, videoPlaylist.url, byActor)
+
+  const serverActor = await getServerActor()
+  const toFollowersOf = [ byActor, serverActor ]
+
+  if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
+
+  return broadcastToFollowers(activity, byActor, toFollowersOf, t)
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   sendDeleteVideo,
   sendDeleteActor,
-  sendDeleteVideoComment
+  sendDeleteVideoComment,
+  sendDeleteVideoPlaylist
 }
 
 // ---------------------------------------------------------------------------
index 839f6647088520906577d7d1e7cb38e5bb4210c3..3eb2704fd6882b544eb615ca4b6b559eb7ea84b5 100644 (file)
@@ -12,8 +12,13 @@ import { audiencify, getActorsInvolvedInVideo, getAudience } from '../audience'
 import { logger } from '../../../helpers/logger'
 import { VideoCaptionModel } from '../../../models/video/video-caption'
 import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
+import { VideoPlaylistModel } from '../../../models/video/video-playlist'
+import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
+import { getServerActor } from '../../../helpers/utils'
 
 async function sendUpdateVideo (video: VideoModel, t: Transaction, overrodeByActor?: ActorModel) {
+  if (video.privacy === VideoPrivacy.PRIVATE) return undefined
+
   logger.info('Creating job to update video %s.', video.url)
 
   const byActor = overrodeByActor ? overrodeByActor : video.VideoChannel.Account.Actor
@@ -73,12 +78,35 @@ async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoR
   return sendVideoRelatedActivity(activityBuilder, { byActor, video })
 }
 
+async function sendUpdateVideoPlaylist (videoPlaylist: VideoPlaylistModel, t: Transaction) {
+  if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
+
+  const byActor = videoPlaylist.OwnerAccount.Actor
+
+  logger.info('Creating job to update video playlist %s.', videoPlaylist.url)
+
+  const url = getUpdateActivityPubUrl(videoPlaylist.url, videoPlaylist.updatedAt.toISOString())
+
+  const object = await videoPlaylist.toActivityPubObject()
+  const audience = getAudience(byActor, videoPlaylist.privacy === VideoPlaylistPrivacy.PUBLIC)
+
+  const updateActivity = buildUpdateActivity(url, byActor, object, audience)
+
+  const serverActor = await getServerActor()
+  const toFollowersOf = [ byActor, serverActor ]
+
+  if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
+
+  return broadcastToFollowers(updateActivity, byActor, toFollowersOf, t)
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   sendUpdateActor,
   sendUpdateVideo,
-  sendUpdateCacheFile
+  sendUpdateCacheFile,
+  sendUpdateVideoPlaylist
 }
 
 // ---------------------------------------------------------------------------
index 4229fe094a1cff1d3450330e42658d42ab7a082a..00bbbba2ddf3ffca48d13bd48ff29c572bf5d2ba 100644 (file)
@@ -7,11 +7,21 @@ import { VideoCommentModel } from '../../models/video/video-comment'
 import { VideoFileModel } from '../../models/video/video-file'
 import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
 import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
+import { VideoPlaylistModel } from '../../models/video/video-playlist'
+import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
 
 function getVideoActivityPubUrl (video: VideoModel) {
   return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
 }
 
+function getVideoPlaylistActivityPubUrl (videoPlaylist: VideoPlaylistModel) {
+  return CONFIG.WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid
+}
+
+function getVideoPlaylistElementActivityPubUrl (videoPlaylist: VideoPlaylistModel, video: VideoModel) {
+  return CONFIG.WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid + '/' + video.uuid
+}
+
 function getVideoCacheFileActivityPubUrl (videoFile: VideoFileModel) {
   const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : ''
 
@@ -98,6 +108,8 @@ function getUndoActivityPubUrl (originalUrl: string) {
 
 export {
   getVideoActivityPubUrl,
+  getVideoPlaylistElementActivityPubUrl,
+  getVideoPlaylistActivityPubUrl,
   getVideoCacheStreamingPlaylistActivityPubUrl,
   getVideoChannelActivityPubUrl,
   getAccountActivityPubUrl,
index 67ccfa9956ec12c3982ad6a5f19b410cc6c76eed..52225f64fd316cc894d98806f8f80d16694e2716 100644 (file)
@@ -5,13 +5,16 @@ import { addVideoComments } from '../../activitypub/video-comments'
 import { crawlCollectionPage } from '../../activitypub/crawl'
 import { VideoModel } from '../../../models/video/video'
 import { addVideoShares, createRates } from '../../activitypub'
+import { createAccountPlaylists } from '../../activitypub/playlist'
+import { AccountModel } from '../../../models/account/account'
 
-type FetchType = 'activity' | 'video-likes' | 'video-dislikes' | 'video-shares' | 'video-comments'
+type FetchType = 'activity' | 'video-likes' | 'video-dislikes' | 'video-shares' | 'video-comments' | 'account-playlists'
 
 export type ActivitypubHttpFetcherPayload = {
   uri: string
   type: FetchType
   videoId?: number
+  accountId?: number
 }
 
 async function processActivityPubHttpFetcher (job: Bull.Job) {
@@ -22,12 +25,16 @@ async function processActivityPubHttpFetcher (job: Bull.Job) {
   let video: VideoModel
   if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
 
+  let account: AccountModel
+  if (payload.accountId) account = await AccountModel.load(payload.accountId)
+
   const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
     'activity': items => processActivities(items, { outboxUrl: payload.uri }),
     'video-likes': items => createRates(items, video, 'like'),
     'video-dislikes': items => createRates(items, video, 'dislike'),
     'video-shares': items => addVideoShares(items, video),
-    'video-comments': items => addVideoComments(items, video)
+    'video-comments': items => addVideoComments(items, video),
+    'account-playlists': items => createAccountPlaylists(items, account)
   }
 
   return crawlCollectionPage(payload.uri, fetcherType[payload.type])
index b3a51e631a5ff255af4d1599149d254bb4a31511..88c57eaa1adb072830b612714b00ffebde2d3c0b 100644 (file)
@@ -17,7 +17,7 @@ const localAccountValidator = [
   }
 ]
 
-const accountsNameWithHostGetValidator = [
+const accountNameWithHostGetValidator = [
   param('accountName').exists().withMessage('Should have an account name with host'),
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@@ -34,5 +34,5 @@ const accountsNameWithHostGetValidator = [
 
 export {
   localAccountValidator,
-  accountsNameWithHostGetValidator
+  accountNameWithHostGetValidator
 }
index 5ceda845fb263bfb6058f9210056f5fcc3c9f41a..ea59fbf73693681e73c7f0e5ee1d1b3a6f6ca8a7 100644 (file)
@@ -19,6 +19,7 @@ const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUM
 const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
 const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
 const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
+const SORTABLE_VIDEO_PLAYLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
 
 const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
 const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@@ -37,6 +38,7 @@ const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COL
 const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS)
 const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
 const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
+const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS)
 
 // ---------------------------------------------------------------------------
 
@@ -57,5 +59,6 @@ export {
   videoChannelsSearchSortValidator,
   accountsBlocklistSortValidator,
   serversBlocklistSortValidator,
-  userNotificationsSortValidator
+  userNotificationsSortValidator,
+  videoPlaylistsSortValidator
 }
index f039794e090c47aca1e097bd863c0f90b2df10b3..c2763ce510c04b2ffe81463db29120357d9c8b75 100644 (file)
@@ -16,19 +16,6 @@ import { areValidationErrors } from '../utils'
 import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor'
 import { ActorModel } from '../../../models/activitypub/actor'
 
-const listVideoAccountChannelsValidator = [
-  param('accountName').exists().withMessage('Should have a valid account name'),
-
-  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking listVideoAccountChannelsValidator parameters', { parameters: req.body })
-
-    if (areValidationErrors(req, res)) return
-    if (!await isAccountNameWithHostExist(req.params.accountName, res)) return
-
-    return next()
-  }
-]
-
 const videoChannelsAddValidator = [
   body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'),
   body('displayName').custom(isVideoChannelNameValid).withMessage('Should have a valid display name'),
@@ -127,7 +114,6 @@ const localVideoChannelValidator = [
 // ---------------------------------------------------------------------------
 
 export {
-  listVideoAccountChannelsValidator,
   videoChannelsAddValidator,
   videoChannelsUpdateValidator,
   videoChannelsRemoveValidator,
index 48d20f904c8228d5462f8a502811f621c286e54c..121df36b67248f206d61ee49f2c0829de2a3d2c2 100644 (file)
@@ -3,14 +3,14 @@ import { body } from 'express-validator/check'
 import { isIdValid } from '../../../helpers/custom-validators/misc'
 import { logger } from '../../../helpers/logger'
 import { areValidationErrors } from '../utils'
-import { getCommonVideoAttributes } from './videos'
+import { getCommonVideoEditAttributes } from './videos'
 import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
 import { cleanUpReqFiles } from '../../../helpers/express-utils'
 import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos'
 import { CONFIG } from '../../../initializers/constants'
 import { CONSTRAINTS_FIELDS } from '../../../initializers'
 
-const videoImportAddValidator = getCommonVideoAttributes().concat([
+const videoImportAddValidator = getCommonVideoEditAttributes().concat([
   body('channelId')
     .toInt()
     .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 (file)
index 0000000..ef8d0b8
--- /dev/null
@@ -0,0 +1,302 @@
+import * as express from 'express'
+import { body, param, ValidationChain } from 'express-validator/check'
+import { UserRight, VideoPrivacy } from '../../../../shared'
+import { logger } from '../../../helpers/logger'
+import { UserModel } from '../../../models/account/user'
+import { areValidationErrors } from '../utils'
+import { isVideoExist, isVideoImage } from '../../../helpers/custom-validators/videos'
+import { CONSTRAINTS_FIELDS } from '../../../initializers'
+import { isIdOrUUIDValid, toValueOrNull } from '../../../helpers/custom-validators/misc'
+import {
+  isVideoPlaylistDescriptionValid,
+  isVideoPlaylistExist,
+  isVideoPlaylistNameValid,
+  isVideoPlaylistPrivacyValid
+} from '../../../helpers/custom-validators/video-playlists'
+import { VideoPlaylistModel } from '../../../models/video/video-playlist'
+import { cleanUpReqFiles } from '../../../helpers/express-utils'
+import { isVideoChannelIdExist } from '../../../helpers/custom-validators/video-channels'
+import { VideoPlaylistElementModel } from '../../../models/video/video-playlist-element'
+import { VideoModel } from '../../../models/video/video'
+import { authenticatePromiseIfNeeded } from '../../oauth'
+import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
+
+const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoPlaylistsAddValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
+
+    if (req.body.videoChannelId && !await isVideoChannelIdExist(req.body.videoChannelId, res)) return cleanUpReqFiles(req)
+
+    return next()
+  }
+])
+
+const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
+  param('playlistId')
+    .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoPlaylistsUpdateValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
+
+    if (!await isVideoPlaylistExist(req.params.playlistId, res)) return cleanUpReqFiles(req)
+    if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, res.locals.videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) {
+      return cleanUpReqFiles(req)
+    }
+
+    if (req.body.videoChannelId && !await isVideoChannelIdExist(req.body.videoChannelId, res)) return cleanUpReqFiles(req)
+
+    return next()
+  }
+])
+
+const videoPlaylistsDeleteValidator = [
+  param('playlistId')
+    .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoPlaylistsDeleteValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    if (!await isVideoPlaylistExist(req.params.playlistId, res)) return
+    if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, res.locals.videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) {
+      return
+    }
+
+    return next()
+  }
+]
+
+const videoPlaylistsGetValidator = [
+  param('playlistId')
+    .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoPlaylistsGetValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    if (!await isVideoPlaylistExist(req.params.playlistId, res)) return
+
+    const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
+    if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
+      await authenticatePromiseIfNeeded(req, res)
+
+      const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : null
+
+      if (
+        !user ||
+        (videoPlaylist.OwnerAccount.userId !== user.id && !user.hasRight(UserRight.UPDATE_ANY_VIDEO_PLAYLIST))
+      ) {
+        return res.status(403)
+                  .json({ error: 'Cannot get this private video playlist.' })
+      }
+
+      return next()
+    }
+
+    return next()
+  }
+]
+
+const videoPlaylistsAddVideoValidator = [
+  param('playlistId')
+    .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
+  body('videoId')
+    .custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'),
+  body('startTimestamp')
+    .optional()
+    .isInt({ min: 0 }).withMessage('Should have a valid start timestamp'),
+  body('stopTimestamp')
+    .optional()
+    .isInt({ min: 0 }).withMessage('Should have a valid stop timestamp'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoPlaylistsAddVideoValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    if (!await isVideoPlaylistExist(req.params.playlistId, res)) return
+    if (!await isVideoExist(req.body.videoId, res, 'id')) return
+
+    const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
+    const video: VideoModel = res.locals.video
+
+    const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideo(videoPlaylist.id, video.id)
+    if (videoPlaylistElement) {
+      res.status(409)
+         .json({ error: 'This video in this playlist already exists' })
+         .end()
+
+      return
+    }
+
+    if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, res.locals.videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) {
+      return
+    }
+
+    return next()
+  }
+]
+
+const videoPlaylistsUpdateOrRemoveVideoValidator = [
+  param('playlistId')
+    .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
+  param('videoId')
+    .custom(isIdOrUUIDValid).withMessage('Should have an video id/uuid'),
+  body('startTimestamp')
+    .optional()
+    .isInt({ min: 0 }).withMessage('Should have a valid start timestamp'),
+  body('stopTimestamp')
+    .optional()
+    .isInt({ min: 0 }).withMessage('Should have a valid stop timestamp'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoPlaylistsRemoveVideoValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    if (!await isVideoPlaylistExist(req.params.playlistId, res)) return
+    if (!await isVideoExist(req.params.playlistId, res, 'id')) return
+
+    const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
+    const video: VideoModel = res.locals.video
+
+    const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideo(videoPlaylist.id, video.id)
+    if (!videoPlaylistElement) {
+      res.status(404)
+         .json({ error: 'Video playlist element not found' })
+         .end()
+
+      return
+    }
+    res.locals.videoPlaylistElement = videoPlaylistElement
+
+    if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) return
+
+    return next()
+  }
+]
+
+const videoPlaylistElementAPGetValidator = [
+  param('playlistId')
+    .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
+  param('videoId')
+    .custom(isIdOrUUIDValid).withMessage('Should have an video id/uuid'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoPlaylistElementAPGetValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideoForAP(req.params.playlistId, req.params.videoId)
+    if (!videoPlaylistElement) {
+      res.status(404)
+         .json({ error: 'Video playlist element not found' })
+         .end()
+
+      return
+    }
+
+    if (videoPlaylistElement.VideoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
+      return res.status(403).end()
+    }
+
+    res.locals.videoPlaylistElement = videoPlaylistElement
+
+    return next()
+  }
+]
+
+const videoPlaylistsReorderVideosValidator = [
+  param('playlistId')
+    .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
+  body('startPosition')
+    .isInt({ min: 1 }).withMessage('Should have a valid start position'),
+  body('insertAfterPosition')
+    .isInt({ min: 0 }).withMessage('Should have a valid insert after position'),
+  body('reorderLength')
+    .optional()
+    .isInt({ min: 1 }).withMessage('Should have a valid range length'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoPlaylistsReorderVideosValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    if (!await isVideoPlaylistExist(req.params.playlistId, res)) return
+
+    const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
+    if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) return
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  videoPlaylistsAddValidator,
+  videoPlaylistsUpdateValidator,
+  videoPlaylistsDeleteValidator,
+  videoPlaylistsGetValidator,
+
+  videoPlaylistsAddVideoValidator,
+  videoPlaylistsUpdateOrRemoveVideoValidator,
+  videoPlaylistsReorderVideosValidator,
+
+  videoPlaylistElementAPGetValidator
+}
+
+// ---------------------------------------------------------------------------
+
+function getCommonPlaylistEditAttributes () {
+  return [
+    body('thumbnailfile')
+      .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
+      'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
+      + CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ')
+    ),
+
+    body('displayName')
+      .custom(isVideoPlaylistNameValid).withMessage('Should have a valid display name'),
+    body('description')
+      .optional()
+      .customSanitizer(toValueOrNull)
+      .custom(isVideoPlaylistDescriptionValid).withMessage('Should have a valid description'),
+    body('privacy')
+      .optional()
+      .toInt()
+      .custom(isVideoPlaylistPrivacyValid).withMessage('Should have correct playlist privacy'),
+    body('videoChannelId')
+      .optional()
+      .toInt()
+  ] as (ValidationChain | express.Handler)[]
+}
+
+function checkUserCanManageVideoPlaylist (user: UserModel, videoPlaylist: VideoPlaylistModel, right: UserRight, res: express.Response) {
+  if (videoPlaylist.isOwned() === false) {
+    res.status(403)
+       .json({ error: 'Cannot manage video playlist of another server.' })
+       .end()
+
+    return false
+  }
+
+  // Check if the user can manage the video playlist
+  // The user can delete it if s/he is an admin
+  // Or if s/he is the video playlist's owner
+  if (user.hasRight(right) === false && videoPlaylist.ownerAccountId !== user.Account.id) {
+    res.status(403)
+       .json({ error: 'Cannot manage video playlist of another user' })
+       .end()
+
+    return false
+  }
+
+  return true
+}
index 159727e2856d11dea05aa172e414ac27ae30f56e..a5e3ed0dcf36f41cc9d37be1a40ca8dca93e92e7 100644 (file)
@@ -46,7 +46,7 @@ import { VideoFetchType } from '../../../helpers/video'
 import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
 import { getServerActor } from '../../../helpers/utils'
 
-const videosAddValidator = getCommonVideoAttributes().concat([
+const videosAddValidator = getCommonVideoEditAttributes().concat([
   body('videofile')
     .custom((value, { req }) => isVideoFile(req.files)).withMessage(
       '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([
   }
 ])
 
-const videosUpdateValidator = getCommonVideoAttributes().concat([
+const videosUpdateValidator = getCommonVideoEditAttributes().concat([
   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
   body('name')
     .optional()
@@ -288,7 +288,7 @@ const videosAcceptChangeOwnershipValidator = [
   }
 ]
 
-function getCommonVideoAttributes () {
+function getCommonVideoEditAttributes () {
   return [
     body('thumbnailfile')
       .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
@@ -421,7 +421,7 @@ export {
   videosTerminateChangeOwnershipValidator,
   videosAcceptChangeOwnershipValidator,
 
-  getCommonVideoAttributes,
+  getCommonVideoEditAttributes,
 
   commonVideosFiltersValidator
 }
index ee22d85281898503fded712a66aa74814f98da37..3fb766c8a9772cc454f7ccdb83bfcec203a12638 100644 (file)
@@ -10,11 +10,11 @@ import {
   ForeignKey,
   HasMany,
   Is,
-  Model,
+  Model, Scopes,
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { Account } from '../../../shared/models/actors'
+import { Account, AccountSummary } from '../../../shared/models/actors'
 import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
 import { sendDeleteActor } from '../../lib/activitypub/send'
 import { ActorModel } from '../activitypub/actor'
@@ -25,6 +25,13 @@ import { VideoChannelModel } from '../video/video-channel'
 import { VideoCommentModel } from '../video/video-comment'
 import { UserModel } from './user'
 import { CONFIG } from '../../initializers'
+import { AvatarModel } from '../avatar/avatar'
+import { WhereOptions } from 'sequelize'
+import { VideoPlaylistModel } from '../video/video-playlist'
+
+export enum ScopeNames {
+  SUMMARY = 'SUMMARY'
+}
 
 @DefaultScope({
   include: [
@@ -34,6 +41,32 @@ import { CONFIG } from '../../initializers'
     }
   ]
 })
+@Scopes({
+  [ ScopeNames.SUMMARY ]: (whereActor?: WhereOptions<ActorModel>) => {
+    return {
+      attributes: [ 'id', 'name' ],
+      include: [
+        {
+          attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
+          model: ActorModel.unscoped(),
+          required: true,
+          where: whereActor,
+          include: [
+            {
+              attributes: [ 'host' ],
+              model: ServerModel.unscoped(),
+              required: false
+            },
+            {
+              model: AvatarModel.unscoped(),
+              required: false
+            }
+          ]
+        }
+      ]
+    }
+  }
+})
 @Table({
   tableName: 'account',
   indexes: [
@@ -112,6 +145,15 @@ export class AccountModel extends Model<AccountModel> {
   })
   VideoChannels: VideoChannelModel[]
 
+  @HasMany(() => VideoPlaylistModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'cascade',
+    hooks: true
+  })
+  VideoPlaylists: VideoPlaylistModel[]
+
   @HasMany(() => VideoCommentModel, {
     foreignKey: {
       allowNull: false
@@ -285,6 +327,20 @@ export class AccountModel extends Model<AccountModel> {
     return Object.assign(actor, account)
   }
 
+  toFormattedSummaryJSON (): AccountSummary {
+    const actor = this.Actor.toFormattedJSON()
+
+    return {
+      id: this.id,
+      uuid: actor.uuid,
+      name: actor.name,
+      displayName: this.getDisplayName(),
+      url: actor.url,
+      host: actor.host,
+      avatar: actor.avatar
+    }
+  }
+
   toActivityPubObject () {
     const obj = this.Actor.toActivityPubObject(this.name, 'Account')
 
index 796e07a42d783b0cb120805ffac74b114effe095..e3eeb7dae1dc64b015b9cecde6d9258f2fa28024 100644 (file)
@@ -407,7 +407,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
                            })
   }
 
-  static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
+  static listAcceptedFollowerUrlsForAP (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
     return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
   }
 
index 49f82023b48ab9ba60749d97aec8010f43576853..2fceb21ddf87cec326bfb59be39725361ce0b66e 100644 (file)
@@ -444,6 +444,7 @@ export class ActorModel extends Model<ActorModel> {
       id: this.url,
       following: this.getFollowingUrl(),
       followers: this.getFollowersUrl(),
+      playlists: this.getPlaylistsUrl(),
       inbox: this.inboxUrl,
       outbox: this.outboxUrl,
       preferredUsername: this.preferredUsername,
@@ -494,6 +495,10 @@ export class ActorModel extends Model<ActorModel> {
     return this.url + '/followers'
   }
 
+  getPlaylistsUrl () {
+    return this.url + '/playlists'
+  }
+
   getPublicKeyUrl () {
     return this.url + '#main-key'
   }
index 5b4093aec40d7b2aee217810c437da749b167afd..4ebd07dabbec5c1745d0faddc2fab73c040df554 100644 (file)
@@ -1,4 +1,5 @@
 import { Sequelize } from 'sequelize-typescript'
+import * as validator from 'validator'
 
 type SortType = { sortModel: any, sortValue: string }
 
@@ -74,13 +75,25 @@ function buildBlockedAccountSQL (serverAccountId: number, userAccountId?: number
 
   const blockerIdsString = blockerIds.join(', ')
 
-  const query = 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
+  return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
     ' UNION ALL ' +
     'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
     'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
     'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
+}
+
+function buildServerIdsFollowedBy (actorId: any) {
+  const actorIdNumber = parseInt(actorId + '', 10)
+
+  return '(' +
+    'SELECT "actor"."serverId" FROM "actorFollow" ' +
+    'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
+    'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
+  ')'
+}
 
-  return query
+function buildWhereIdOrUUID (id: number | string) {
+  return validator.isInt('' + id) ? { id } : { uuid: id }
 }
 
 // ---------------------------------------------------------------------------
@@ -93,7 +106,9 @@ export {
   getSortOnModel,
   createSimilarityAttribute,
   throwIfNotValid,
-  buildTrigramSearchIndex
+  buildServerIdsFollowedBy,
+  buildTrigramSearchIndex,
+  buildWhereIdOrUUID
 }
 
 // ---------------------------------------------------------------------------
index 2426b3de692fdec6866ad0e054a5b14611dedead..112abf8cfad7150553031898d25940ed1a2e17ff 100644 (file)
@@ -8,7 +8,7 @@ import {
   Default,
   DefaultScope,
   ForeignKey,
-  HasMany,
+  HasMany, IFindOptions,
   Is,
   Model,
   Scopes,
@@ -17,20 +17,22 @@ import {
   UpdatedAt
 } from 'sequelize-typescript'
 import { ActivityPubActor } from '../../../shared/models/activitypub'
-import { VideoChannel } from '../../../shared/models/videos'
+import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
 import {
   isVideoChannelDescriptionValid,
   isVideoChannelNameValid,
   isVideoChannelSupportValid
 } from '../../helpers/custom-validators/video-channels'
 import { sendDeleteActor } from '../../lib/activitypub/send'
-import { AccountModel } from '../account/account'
+import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account'
 import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
-import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
+import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
 import { VideoModel } from './video'
 import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
 import { ServerModel } from '../server/server'
 import { DefineIndexesOptions } from 'sequelize'
+import { AvatarModel } from '../avatar/avatar'
+import { VideoPlaylistModel } from './video-playlist'
 
 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
 const indexes: DefineIndexesOptions[] = [
@@ -44,11 +46,12 @@ const indexes: DefineIndexesOptions[] = [
   }
 ]
 
-enum ScopeNames {
+export enum ScopeNames {
   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
   WITH_ACCOUNT = 'WITH_ACCOUNT',
   WITH_ACTOR = 'WITH_ACTOR',
-  WITH_VIDEOS = 'WITH_VIDEOS'
+  WITH_VIDEOS = 'WITH_VIDEOS',
+  SUMMARY = 'SUMMARY'
 }
 
 type AvailableForListOptions = {
@@ -64,15 +67,41 @@ type AvailableForListOptions = {
   ]
 })
 @Scopes({
-  [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
-    const actorIdNumber = parseInt(options.actorId + '', 10)
+  [ScopeNames.SUMMARY]: (required: boolean, withAccount: boolean) => {
+    const base: IFindOptions<VideoChannelModel> = {
+      attributes: [ 'name', 'description', 'id' ],
+      include: [
+        {
+          attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
+          model: ActorModel.unscoped(),
+          required: true,
+          include: [
+            {
+              attributes: [ 'host' ],
+              model: ServerModel.unscoped(),
+              required: false
+            },
+            {
+              model: AvatarModel.unscoped(),
+              required: false
+            }
+          ]
+        }
+      ]
+    }
+
+    if (withAccount === true) {
+      base.include.push({
+        model: AccountModel.scope(AccountModelScopeNames.SUMMARY),
+        required: true
+      })
+    }
 
+    return base
+  },
+  [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
     // Only list local channels OR channels that are on an instance followed by actorId
-    const inQueryInstanceFollow = '(' +
-      'SELECT "actor"."serverId" FROM "actorFollow" ' +
-      'INNER JOIN "actor" ON actor.id=  "actorFollow"."targetActorId" ' +
-      'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
-    ')'
+    const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
 
     return {
       include: [
@@ -192,6 +221,15 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
   })
   Videos: VideoModel[]
 
+  @HasMany(() => VideoPlaylistModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'cascade',
+    hooks: true
+  })
+  VideoPlaylists: VideoPlaylistModel[]
+
   @BeforeDestroy
   static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
     if (!instance.Actor) {
@@ -460,6 +498,20 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
     return Object.assign(actor, videoChannel)
   }
 
+  toFormattedSummaryJSON (): VideoChannelSummary {
+    const actor = this.Actor.toFormattedJSON()
+
+    return {
+      id: this.id,
+      uuid: actor.uuid,
+      name: actor.name,
+      displayName: this.getDisplayName(),
+      url: actor.url,
+      host: actor.host,
+      avatar: actor.avatar
+    }
+  }
+
   toActivityPubObject (): ActivityPubActor {
     const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')
 
index a62335333cd843263dd4703efc6d438e8cb383e4..dc10fb9a235c25e443aa76587bc110354964995b 100644 (file)
@@ -26,12 +26,10 @@ export type VideoFormattingJSONOptions = {
     waitTranscoding?: boolean,
     scheduledUpdate?: boolean,
     blacklistInfo?: boolean
+    playlistInfo?: boolean
   }
 }
 function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
-  const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
-  const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
-
   const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
 
   const videoObject: Video = {
@@ -68,24 +66,9 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
     updatedAt: video.updatedAt,
     publishedAt: video.publishedAt,
     originallyPublishedAt: video.originallyPublishedAt,
-    account: {
-      id: formattedAccount.id,
-      uuid: formattedAccount.uuid,
-      name: formattedAccount.name,
-      displayName: formattedAccount.displayName,
-      url: formattedAccount.url,
-      host: formattedAccount.host,
-      avatar: formattedAccount.avatar
-    },
-    channel: {
-      id: formattedVideoChannel.id,
-      uuid: formattedVideoChannel.uuid,
-      name: formattedVideoChannel.name,
-      displayName: formattedVideoChannel.displayName,
-      url: formattedVideoChannel.url,
-      host: formattedVideoChannel.host,
-      avatar: formattedVideoChannel.avatar
-    },
+
+    account: video.VideoChannel.Account.toFormattedSummaryJSON(),
+    channel: video.VideoChannel.toFormattedSummaryJSON(),
 
     userHistory: userHistory ? {
       currentTime: userHistory.currentTime
@@ -115,6 +98,17 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
       videoObject.blacklisted = !!video.VideoBlacklist
       videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
     }
+
+    if (options.additionalAttributes.playlistInfo === true) {
+      // We filtered on a specific videoId/videoPlaylistId, that is unique
+      const playlistElement = video.VideoPlaylistElements[0]
+
+      videoObject.playlistElement = {
+        position: playlistElement.position,
+        startTimestamp: playlistElement.startTimestamp,
+        stopTimestamp: playlistElement.stopTimestamp
+      }
+    }
   }
 
   return videoObject
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
new file mode 100644 (file)
index 0000000..d76149d
--- /dev/null
@@ -0,0 +1,231 @@
+import {
+  AllowNull,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  DataType,
+  Default,
+  ForeignKey,
+  Is,
+  IsInt,
+  Min,
+  Model,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import { VideoModel } from './video'
+import { VideoPlaylistModel } from './video-playlist'
+import * as Sequelize from 'sequelize'
+import { getSort, throwIfNotValid } from '../utils'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
+import { CONSTRAINTS_FIELDS } from '../../initializers'
+import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
+
+@Table({
+  tableName: 'videoPlaylistElement',
+  indexes: [
+    {
+      fields: [ 'videoPlaylistId' ]
+    },
+    {
+      fields: [ 'videoId' ]
+    },
+    {
+      fields: [ 'videoPlaylistId', 'videoId' ],
+      unique: true
+    },
+    {
+      fields: [ 'videoPlaylistId', 'position' ],
+      unique: true
+    },
+    {
+      fields: [ 'url' ],
+      unique: true
+    }
+  ]
+})
+export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel> {
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @AllowNull(false)
+  @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
+  url: string
+
+  @AllowNull(false)
+  @Default(1)
+  @IsInt
+  @Min(1)
+  @Column
+  position: number
+
+  @AllowNull(true)
+  @IsInt
+  @Min(0)
+  @Column
+  startTimestamp: number
+
+  @AllowNull(true)
+  @IsInt
+  @Min(0)
+  @Column
+  stopTimestamp: number
+
+  @ForeignKey(() => VideoPlaylistModel)
+  @Column
+  videoPlaylistId: number
+
+  @BelongsTo(() => VideoPlaylistModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  VideoPlaylist: VideoPlaylistModel
+
+  @ForeignKey(() => VideoModel)
+  @Column
+  videoId: number
+
+  @BelongsTo(() => VideoModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  Video: VideoModel
+
+  static deleteAllOf (videoPlaylistId: number, transaction?: Sequelize.Transaction) {
+    const query = {
+      where: {
+        videoPlaylistId
+      },
+      transaction
+    }
+
+    return VideoPlaylistElementModel.destroy(query)
+  }
+
+  static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number) {
+    const query = {
+      where: {
+        videoPlaylistId,
+        videoId
+      }
+    }
+
+    return VideoPlaylistElementModel.findOne(query)
+  }
+
+  static loadByPlaylistAndVideoForAP (playlistId: number | string, videoId: number | string) {
+    const playlistWhere = validator.isUUID('' + playlistId) ? { uuid: playlistId } : { id: playlistId }
+    const videoWhere = validator.isUUID('' + videoId) ? { uuid: videoId } : { id: videoId }
+
+    const query = {
+      include: [
+        {
+          attributes: [ 'privacy' ],
+          model: VideoPlaylistModel.unscoped(),
+          where: playlistWhere
+        },
+        {
+          attributes: [ 'url' ],
+          model: VideoModel.unscoped(),
+          where: videoWhere
+        }
+      ]
+    }
+
+    return VideoPlaylistElementModel.findOne(query)
+  }
+
+  static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number) {
+    const query = {
+      attributes: [ 'url' ],
+      offset: start,
+      limit: count,
+      order: getSort('position'),
+      where: {
+        videoPlaylistId
+      }
+    }
+
+    return VideoPlaylistElementModel
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return { total: count, data: rows.map(e => e.url) }
+      })
+  }
+
+  static getNextPositionOf (videoPlaylistId: number, transaction?: Sequelize.Transaction) {
+    const query = {
+      where: {
+        videoPlaylistId
+      },
+      transaction
+    }
+
+    return VideoPlaylistElementModel.max('position', query)
+      .then(position => position ? position + 1 : 1)
+  }
+
+  static reassignPositionOf (
+    videoPlaylistId: number,
+    firstPosition: number,
+    endPosition: number,
+    newPosition: number,
+    transaction?: Sequelize.Transaction
+  ) {
+    const query = {
+      where: {
+        videoPlaylistId,
+        position: {
+          [Sequelize.Op.gte]: firstPosition,
+          [Sequelize.Op.lte]: endPosition
+        }
+      },
+      transaction
+    }
+
+    return VideoPlaylistElementModel.update({ position: Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`) }, query)
+  }
+
+  static increasePositionOf (
+    videoPlaylistId: number,
+    fromPosition: number,
+    toPosition?: number,
+    by = 1,
+    transaction?: Sequelize.Transaction
+  ) {
+    const query = {
+      where: {
+        videoPlaylistId,
+        position: {
+          [Sequelize.Op.gte]: fromPosition
+        }
+      },
+      transaction
+    }
+
+    return VideoPlaylistElementModel.increment({ position: by }, query)
+  }
+
+  toActivityPubObject (): PlaylistElementObject {
+    const base: PlaylistElementObject = {
+      id: this.url,
+      type: 'PlaylistElement',
+
+      url: this.Video.url,
+      position: this.position
+    }
+
+    if (this.startTimestamp) base.startTimestamp = this.startTimestamp
+    if (this.stopTimestamp) base.stopTimestamp = this.stopTimestamp
+
+    return base
+  }
+}
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
new file mode 100644 (file)
index 0000000..93b8c2f
--- /dev/null
@@ -0,0 +1,381 @@
+import {
+  AllowNull,
+  BeforeDestroy,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  DataType,
+  Default,
+  ForeignKey,
+  HasMany,
+  Is,
+  IsUUID,
+  Model,
+  Scopes,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import * as Sequelize from 'sequelize'
+import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
+import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, throwIfNotValid } from '../utils'
+import {
+  isVideoPlaylistDescriptionValid,
+  isVideoPlaylistNameValid,
+  isVideoPlaylistPrivacyValid
+} from '../../helpers/custom-validators/video-playlists'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
+import { CONFIG, CONSTRAINTS_FIELDS, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers'
+import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
+import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
+import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
+import { join } from 'path'
+import { VideoPlaylistElementModel } from './video-playlist-element'
+import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
+import { activityPubCollectionPagination } from '../../helpers/activitypub'
+import { remove } from 'fs-extra'
+import { logger } from '../../helpers/logger'
+
+enum ScopeNames {
+  AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
+  WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
+  WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
+}
+
+type AvailableForListOptions = {
+  followerActorId: number
+  accountId?: number,
+  videoChannelId?: number
+  privateAndUnlisted?: boolean
+}
+
+@Scopes({
+  [ScopeNames.WITH_VIDEOS_LENGTH]: {
+    attributes: {
+      include: [
+        [
+          Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
+          'videosLength'
+        ]
+      ]
+    }
+  },
+  [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: {
+    include: [
+      {
+        model: () => AccountModel.scope(AccountScopeNames.SUMMARY),
+        required: true
+      },
+      {
+        model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
+        required: false
+      }
+    ]
+  },
+  [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
+    // Only list local playlists OR playlists that are on an instance followed by actorId
+    const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
+    const actorWhere = {
+      [ Sequelize.Op.or ]: [
+        {
+          serverId: null
+        },
+        {
+          serverId: {
+            [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow)
+          }
+        }
+      ]
+    }
+
+    const whereAnd: any[] = []
+
+    if (options.privateAndUnlisted !== true) {
+      whereAnd.push({
+        privacy: VideoPlaylistPrivacy.PUBLIC
+      })
+    }
+
+    if (options.accountId) {
+      whereAnd.push({
+        ownerAccountId: options.accountId
+      })
+    }
+
+    if (options.videoChannelId) {
+      whereAnd.push({
+        videoChannelId: options.videoChannelId
+      })
+    }
+
+    const where = {
+      [Sequelize.Op.and]: whereAnd
+    }
+
+    const accountScope = {
+      method: [ AccountScopeNames.SUMMARY, actorWhere ]
+    }
+
+    return {
+      where,
+      include: [
+        {
+          model: AccountModel.scope(accountScope),
+          required: true
+        },
+        {
+          model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
+          required: false
+        }
+      ]
+    }
+  }
+})
+
+@Table({
+  tableName: 'videoPlaylist',
+  indexes: [
+    {
+      fields: [ 'ownerAccountId' ]
+    },
+    {
+      fields: [ 'videoChannelId' ]
+    },
+    {
+      fields: [ 'url' ],
+      unique: true
+    }
+  ]
+})
+export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @AllowNull(false)
+  @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
+  @Column
+  name: string
+
+  @AllowNull(true)
+  @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description'))
+  @Column
+  description: string
+
+  @AllowNull(false)
+  @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
+  @Column
+  privacy: VideoPlaylistPrivacy
+
+  @AllowNull(false)
+  @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
+  url: string
+
+  @AllowNull(false)
+  @Default(DataType.UUIDV4)
+  @IsUUID(4)
+  @Column(DataType.UUID)
+  uuid: string
+
+  @ForeignKey(() => AccountModel)
+  @Column
+  ownerAccountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  OwnerAccount: AccountModel
+
+  @ForeignKey(() => VideoChannelModel)
+  @Column
+  videoChannelId: number
+
+  @BelongsTo(() => VideoChannelModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  VideoChannel: VideoChannelModel
+
+  @HasMany(() => VideoPlaylistElementModel, {
+    foreignKey: {
+      name: 'videoPlaylistId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoPlaylistElements: VideoPlaylistElementModel[]
+
+  // Calculated field
+  videosLength?: number
+
+  @BeforeDestroy
+  static async removeFiles (instance: VideoPlaylistModel) {
+    logger.info('Removing files of video playlist %s.', instance.url)
+
+    return instance.removeThumbnail()
+  }
+
+  static listForApi (options: {
+    followerActorId: number
+    start: number,
+    count: number,
+    sort: string,
+    accountId?: number,
+    videoChannelId?: number,
+    privateAndUnlisted?: boolean
+  }) {
+    const query = {
+      offset: options.start,
+      limit: options.count,
+      order: getSort(options.sort)
+    }
+
+    const scopes = [
+      {
+        method: [
+          ScopeNames.AVAILABLE_FOR_LIST,
+          {
+            followerActorId: options.followerActorId,
+            accountId: options.accountId,
+            videoChannelId: options.videoChannelId,
+            privateAndUnlisted: options.privateAndUnlisted
+          } as AvailableForListOptions
+        ]
+      } as any, // FIXME: typings
+      ScopeNames.WITH_VIDEOS_LENGTH
+    ]
+
+    return VideoPlaylistModel
+      .scope(scopes)
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return { total: count, data: rows }
+      })
+  }
+
+  static listUrlsOfForAP (accountId: number, start: number, count: number) {
+    const query = {
+      attributes: [ 'url' ],
+      offset: start,
+      limit: count,
+      where: {
+        ownerAccountId: accountId
+      }
+    }
+
+    return VideoPlaylistModel.findAndCountAll(query)
+                             .then(({ rows, count }) => {
+                               return { total: count, data: rows.map(p => p.url) }
+                             })
+  }
+
+  static doesPlaylistExist (url: string) {
+    const query = {
+      attributes: [],
+      where: {
+        url
+      }
+    }
+
+    return VideoPlaylistModel
+      .findOne(query)
+      .then(e => !!e)
+  }
+
+  static load (id: number | string, transaction: Sequelize.Transaction) {
+    const where = buildWhereIdOrUUID(id)
+
+    const query = {
+      where,
+      transaction
+    }
+
+    return VideoPlaylistModel
+      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ])
+      .findOne(query)
+  }
+
+  static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
+    return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
+  }
+
+  getThumbnailName () {
+    const extension = '.jpg'
+
+    return 'playlist-' + this.uuid + extension
+  }
+
+  getThumbnailUrl () {
+    return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
+  }
+
+  getThumbnailStaticPath () {
+    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
+  }
+
+  removeThumbnail () {
+    const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
+    return remove(thumbnailPath)
+      .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
+  }
+
+  isOwned () {
+    return this.OwnerAccount.isOwned()
+  }
+
+  toFormattedJSON (): VideoPlaylist {
+    return {
+      id: this.id,
+      uuid: this.uuid,
+      isLocal: this.isOwned(),
+
+      displayName: this.name,
+      description: this.description,
+      privacy: {
+        id: this.privacy,
+        label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
+      },
+
+      thumbnailPath: this.getThumbnailStaticPath(),
+
+      videosLength: this.videosLength,
+
+      createdAt: this.createdAt,
+      updatedAt: this.updatedAt,
+
+      ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
+      videoChannel: this.VideoChannel.toFormattedSummaryJSON()
+    }
+  }
+
+  toActivityPubObject (): Promise<PlaylistObject> {
+    const handler = (start: number, count: number) => {
+      return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count)
+    }
+
+    return activityPubCollectionPagination(this.url, handler, null)
+      .then(o => {
+        return Object.assign(o, {
+          type: 'Playlist' as 'Playlist',
+          name: this.name,
+          content: this.description,
+          uuid: this.uuid,
+          attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
+          icon: {
+            type: 'Image' as 'Image',
+            url: this.getThumbnailUrl(),
+            mediaType: 'image/jpeg' as 'image/jpeg',
+            width: THUMBNAILS_SIZE.width,
+            height: THUMBNAILS_SIZE.height
+          }
+        })
+      })
+  }
+}
index 4516b9c7b88d57b61b74dcd41903cd997c564ca9..7a102b058c6679381046391da70003e77d8aceed 100644 (file)
@@ -40,7 +40,7 @@ import {
   isVideoDurationValid,
   isVideoLanguageValid,
   isVideoLicenceValid,
-  isVideoNameValid, isVideoOriginallyPublishedAtValid,
+  isVideoNameValid,
   isVideoPrivacyValid,
   isVideoStateValid,
   isVideoSupportValid
@@ -52,7 +52,9 @@ import {
   ACTIVITY_PUB,
   API_VERSION,
   CONFIG,
-  CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY,
+  CONSTRAINTS_FIELDS,
+  HLS_PLAYLIST_DIRECTORY,
+  HLS_REDUNDANCY_DIRECTORY,
   PREVIEWS_SIZE,
   REMOTE_SCHEME,
   STATIC_DOWNLOAD_PATHS,
@@ -70,10 +72,17 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
 import { ActorModel } from '../activitypub/actor'
 import { AvatarModel } from '../avatar/avatar'
 import { ServerModel } from '../server/server'
-import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils'
+import {
+  buildBlockedAccountSQL,
+  buildTrigramSearchIndex,
+  buildWhereIdOrUUID,
+  createSimilarityAttribute,
+  getVideoSort,
+  throwIfNotValid
+} from '../utils'
 import { TagModel } from './tag'
 import { VideoAbuseModel } from './video-abuse'
-import { VideoChannelModel } from './video-channel'
+import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel'
 import { VideoCommentModel } from './video-comment'
 import { VideoFileModel } from './video-file'
 import { VideoShareModel } from './video-share'
@@ -91,11 +100,11 @@ import {
   videoModelToFormattedDetailsJSON,
   videoModelToFormattedJSON
 } from './video-format-utils'
-import * as validator from 'validator'
 import { UserVideoHistoryModel } from '../account/user-video-history'
 import { UserModel } from '../account/user'
 import { VideoImportModel } from './video-import'
 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
+import { VideoPlaylistElementModel } from './video-playlist-element'
 
 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
 const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -175,6 +184,9 @@ export enum ScopeNames {
 
 type ForAPIOptions = {
   ids: number[]
+
+  videoPlaylistId?: number
+
   withFiles?: boolean
 }
 
@@ -182,6 +194,7 @@ type AvailableForListIDsOptions = {
   serverAccountId: number
   followerActorId: number
   includeLocalVideos: boolean
+
   filter?: VideoFilter
   categoryOneOf?: number[]
   nsfw?: boolean
@@ -189,9 +202,14 @@ type AvailableForListIDsOptions = {
   languageOneOf?: string[]
   tagsOneOf?: string[]
   tagsAllOf?: string[]
+
   withFiles?: boolean
+
   accountId?: number
   videoChannelId?: number
+
+  videoPlaylistId?: number
+
   trendingDays?: number
   user?: UserModel,
   historyOfUser?: UserModel
@@ -199,62 +217,17 @@ type AvailableForListIDsOptions = {
 
 @Scopes({
   [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
-    const accountInclude = {
-      attributes: [ 'id', 'name' ],
-      model: AccountModel.unscoped(),
-      required: true,
-      include: [
-        {
-          attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
-          model: ActorModel.unscoped(),
-          required: true,
-          include: [
-            {
-              attributes: [ 'host' ],
-              model: ServerModel.unscoped(),
-              required: false
-            },
-            {
-              model: AvatarModel.unscoped(),
-              required: false
-            }
-          ]
-        }
-      ]
-    }
-
-    const videoChannelInclude = {
-      attributes: [ 'name', 'description', 'id' ],
-      model: VideoChannelModel.unscoped(),
-      required: true,
-      include: [
-        {
-          attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
-          model: ActorModel.unscoped(),
-          required: true,
-          include: [
-            {
-              attributes: [ 'host' ],
-              model: ServerModel.unscoped(),
-              required: false
-            },
-            {
-              model: AvatarModel.unscoped(),
-              required: false
-            }
-          ]
-        },
-        accountInclude
-      ]
-    }
-
     const query: IFindOptions<VideoModel> = {
       where: {
         id: {
           [ Sequelize.Op.any ]: options.ids
         }
       },
-      include: [ videoChannelInclude ]
+      include: [
+        {
+          model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY)
+        }
+      ]
     }
 
     if (options.withFiles === true) {
@@ -264,6 +237,13 @@ type AvailableForListIDsOptions = {
       })
     }
 
+    if (options.videoPlaylistId) {
+      query.include.push({
+        model: VideoPlaylistElementModel.unscoped(),
+        required: true
+      })
+    }
+
     return query
   },
   [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
@@ -315,6 +295,17 @@ type AvailableForListIDsOptions = {
       Object.assign(query.where, privacyWhere)
     }
 
+    if (options.videoPlaylistId) {
+      query.include.push({
+        attributes: [],
+        model: VideoPlaylistElementModel.unscoped(),
+        required: true,
+        where: {
+          videoPlaylistId: options.videoPlaylistId
+        }
+      })
+    }
+
     if (options.filter || options.accountId || options.videoChannelId) {
       const videoChannelInclude: IIncludeOptions = {
         attributes: [],
@@ -772,6 +763,15 @@ export class VideoModel extends Model<VideoModel> {
   })
   Tags: TagModel[]
 
+  @HasMany(() => VideoPlaylistElementModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoPlaylistElements: VideoPlaylistElementModel[]
+
   @HasMany(() => VideoAbuseModel, {
     foreignKey: {
       name: 'videoId',
@@ -1118,6 +1118,7 @@ export class VideoModel extends Model<VideoModel> {
     accountId?: number,
     videoChannelId?: number,
     followerActorId?: number
+    videoPlaylistId?: number,
     trendingDays?: number,
     user?: UserModel,
     historyOfUser?: UserModel
@@ -1157,6 +1158,7 @@ export class VideoModel extends Model<VideoModel> {
       withFiles: options.withFiles,
       accountId: options.accountId,
       videoChannelId: options.videoChannelId,
+      videoPlaylistId: options.videoPlaylistId,
       includeLocalVideos: options.includeLocalVideos,
       user: options.user,
       historyOfUser: options.historyOfUser,
@@ -1280,7 +1282,7 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   static load (id: number | string, t?: Sequelize.Transaction) {
-    const where = VideoModel.buildWhereIdOrUUID(id)
+    const where = buildWhereIdOrUUID(id)
     const options = {
       where,
       transaction: t
@@ -1290,7 +1292,7 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
-    const where = VideoModel.buildWhereIdOrUUID(id)
+    const where = buildWhereIdOrUUID(id)
     const options = {
       where,
       transaction: t
@@ -1300,7 +1302,7 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
-    const where = VideoModel.buildWhereIdOrUUID(id)
+    const where = buildWhereIdOrUUID(id)
 
     const options = {
       attributes: [ 'id' ],
@@ -1353,7 +1355,7 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
-    const where = VideoModel.buildWhereIdOrUUID(id)
+    const where = buildWhereIdOrUUID(id)
 
     const options = {
       order: [ [ 'Tags', 'name', 'ASC' ] ],
@@ -1380,7 +1382,7 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) {
-    const where = VideoModel.buildWhereIdOrUUID(id)
+    const where = buildWhereIdOrUUID(id)
 
     const options = {
       order: [ [ 'Tags', 'name', 'ASC' ] ],
@@ -1582,10 +1584,6 @@ export class VideoModel extends Model<VideoModel> {
     return VIDEO_STATES[ id ] || 'Unknown'
   }
 
-  static buildWhereIdOrUUID (id: number | string) {
-    return validator.isInt('' + id) ? { id } : { uuid: id }
-  }
-
   getOriginalFile () {
     if (Array.isArray(this.VideoFiles) === false) return undefined
 
@@ -1598,7 +1596,6 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   getThumbnailName () {
-    // We always have a copy of the thumbnail
     const extension = '.jpg'
     return this.uuid + extension
   }
diff --git a/server/tests/api/check-params/video-playlists.ts b/server/tests/api/check-params/video-playlists.ts
new file mode 100644 (file)
index 0000000..7004bad
--- /dev/null
@@ -0,0 +1,117 @@
+/* tslint:disable:no-unused-expression */
+
+import { omit } from 'lodash'
+import 'mocha'
+import { join } from 'path'
+import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum'
+import {
+  createUser,
+  flushTests,
+  getMyUserInformation,
+  immutableAssign,
+  killallServers,
+  makeGetRequest,
+  makePostBodyRequest,
+  makeUploadRequest,
+  runServer,
+  ServerInfo,
+  setAccessTokensToServers,
+  updateCustomSubConfig,
+  userLogin
+} from '../../../../shared/utils'
+import {
+  checkBadCountPagination,
+  checkBadSortPagination,
+  checkBadStartPagination
+} from '../../../../shared/utils/requests/check-api-params'
+import { getMagnetURI, getYoutubeVideoUrl } from '../../../../shared/utils/videos/video-imports'
+
+describe('Test video playlists API validator', function () {
+  const path = '/api/v1/videos/video-playlists'
+  let server: ServerInfo
+  let userAccessToken = ''
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    await flushTests()
+
+    server = await runServer(1)
+
+    await setAccessTokensToServers([ server ])
+
+    const username = 'user1'
+    const password = 'my super password'
+    await createUser(server.url, server.accessToken, username, password)
+    userAccessToken = await userLogin(server, { username, password })
+  })
+
+  describe('When listing video playlists', function () {
+    const globalPath = '/api/v1/video-playlists'
+    const accountPath = '/api/v1/accounts/root/video-playlists'
+    const videoChannelPath = '/api/v1/video-channels/root_channel/video-playlists'
+
+    it('Should fail with a bad start pagination', async function () {
+      await checkBadStartPagination(server.url, globalPath, server.accessToken)
+      await checkBadStartPagination(server.url, accountPath, server.accessToken)
+      await checkBadStartPagination(server.url, videoChannelPath, server.accessToken)
+    })
+
+    it('Should fail with a bad count pagination', async function () {
+      await checkBadCountPagination(server.url, globalPath, server.accessToken)
+      await checkBadCountPagination(server.url, accountPath, server.accessToken)
+      await checkBadCountPagination(server.url, videoChannelPath, server.accessToken)
+    })
+
+    it('Should fail with an incorrect sort', async function () {
+      await checkBadSortPagination(server.url, globalPath, server.accessToken)
+      await checkBadSortPagination(server.url, accountPath, server.accessToken)
+      await checkBadSortPagination(server.url, videoChannelPath, server.accessToken)
+    })
+
+    it('Should fail with a bad account parameter', async function () {
+      const accountPath = '/api/v1/accounts/root2/video-playlists'
+
+      await makeGetRequest({ url: server.url, path: accountPath, statusCodeExpected: 404, token: server.accessToken })
+    })
+
+    it('Should fail with a bad video channel parameter', async function () {
+      const accountPath = '/api/v1/video-channels/bad_channel/video-playlists'
+
+      await makeGetRequest({ url: server.url, path: accountPath, statusCodeExpected: 404, token: server.accessToken })
+    })
+
+    it('Should success with the correct parameters', async function () {
+      await makeGetRequest({ url: server.url, path: globalPath, statusCodeExpected: 200, token: server.accessToken })
+      await makeGetRequest({ url: server.url, path: accountPath, statusCodeExpected: 200, token: server.accessToken })
+      await makeGetRequest({ url: server.url, path: videoChannelPath, statusCodeExpected: 200, token: server.accessToken })
+    })
+  })
+
+  describe('When listing videos of a playlist', async function () {
+    const path = '/api/v1/video-playlists'
+
+    it('Should fail with a bad start pagination', async function () {
+      await checkBadStartPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with a bad count pagination', async function () {
+      await checkBadCountPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with an incorrect sort', async function () {
+      await checkBadSortPagination(server.url, path, server.accessToken)
+    })
+  })
+
+  after(async function () {
+    killallServers([ server ])
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
new file mode 100644 (file)
index 0000000..cb23239
--- /dev/null
@@ -0,0 +1,161 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import { join } from 'path'
+import * as request from 'supertest'
+import { VideoPrivacy } from '../../../../shared/models/videos'
+import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
+import {
+  addVideoChannel,
+  checkTmpIsEmpty,
+  checkVideoFilesWereRemoved,
+  completeVideoCheck,
+  createUser,
+  dateIsValid,
+  doubleFollow,
+  flushAndRunMultipleServers,
+  flushTests,
+  getLocalVideos,
+  getVideo,
+  getVideoChannelsList,
+  getVideosList,
+  killallServers,
+  rateVideo,
+  removeVideo,
+  ServerInfo,
+  setAccessTokensToServers,
+  testImage,
+  updateVideo,
+  uploadVideo,
+  userLogin,
+  viewVideo,
+  wait,
+  webtorrentAdd
+} from '../../../../shared/utils'
+import {
+  addVideoCommentReply,
+  addVideoCommentThread,
+  deleteVideoComment,
+  getVideoCommentThreads,
+  getVideoThreadComments
+} from '../../../../shared/utils/videos/video-comments'
+import { waitJobs } from '../../../../shared/utils/server/jobs'
+
+const expect = chai.expect
+
+describe('Test video playlists', function () {
+  let servers: ServerInfo[] = []
+
+  before(async function () {
+    this.timeout(120000)
+
+    servers = await flushAndRunMultipleServers(3)
+
+    // Get the access tokens
+    await setAccessTokensToServers(servers)
+
+    // Server 1 and server 2 follow each other
+    await doubleFollow(servers[0], servers[1])
+    // Server 1 and server 3 follow each other
+    await doubleFollow(servers[0], servers[2])
+  })
+
+  it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () {
+
+  })
+
+  it('Should create a playlist on server 2 and have the playlist on server 1 but not on server 3', async function () {
+    // create 2 playlists (with videos and no videos)
+    // With thumbnail and no thumbnail
+  })
+
+  it('Should have the playlist on server 3 after a new follow', async function () {
+    // Server 2 and server 3 follow each other
+    await doubleFollow(servers[1], servers[2])
+  })
+
+  it('Should create some playlists and list them correctly', async function () {
+    // create 3 playlists with some videos in it
+    // check pagination
+    // check sort
+    // check empty
+  })
+
+  it('Should list video channel playlists', async function () {
+    // check pagination
+    // check sort
+    // check empty
+  })
+
+  it('Should list account playlists', async function () {
+    // check pagination
+    // check sort
+    // check empty
+  })
+
+  it('Should get a playlist', async function () {
+    // get empty playlist
+    // get non empty playlist
+  })
+
+  it('Should update a playlist', async function () {
+    // update thumbnail
+
+    // update other details
+  })
+
+  it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () {
+
+  })
+
+  it('Should correctly list playlist videos', async function () {
+    // empty
+    // some filters?
+  })
+
+  it('Should reorder the playlist', async function () {
+    // reorder 1 element
+    // reorder 3 elements
+    // reorder at the beginning
+    // reorder at the end
+    // reorder before/after
+  })
+
+  it('Should update startTimestamp/endTimestamp of some elements', async function () {
+
+  })
+
+  it('Should delete some elements', async function () {
+
+  })
+
+  it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () {
+
+  })
+
+  it('Should have deleted the thumbnail on server 1, 2 and 3', async function () {
+
+  })
+
+  it('Should unfollow servers 1 and 2 and hide their playlists', async function () {
+
+  })
+
+  it('Should delete a channel and remove the associated playlist', async function () {
+
+  })
+
+  it('Should delete an account and delete its playlists', async function () {
+
+  })
+
+  after(async function () {
+    killallServers(servers)
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})
index 89994f6650f2c272976272e7d4fcb1e3799c9f8c..95801190d83d07223e0b92c699e0b1a5bdaaf787 100644 (file)
@@ -6,6 +6,7 @@ import { VideoAbuseObject } from './objects/video-abuse-object'
 import { VideoCommentObject } from './objects/video-comment-object'
 import { ViewObject } from './objects/view-object'
 import { APObject } from './objects/object.model'
+import { PlaylistObject } from './objects/playlist-object'
 
 export type Activity = ActivityCreate | ActivityUpdate |
   ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce |
@@ -31,12 +32,12 @@ export interface BaseActivity {
 
 export interface ActivityCreate extends BaseActivity {
   type: 'Create'
-  object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject
+  object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject
 }
 
 export interface ActivityUpdate extends BaseActivity {
   type: 'Update'
-  object: VideoTorrentObject | ActivityPubActor | CacheFileObject
+  object: VideoTorrentObject | ActivityPubActor | CacheFileObject | PlaylistObject
 }
 
 export interface ActivityDelete extends BaseActivity {
index 119bc22d4e621e5a4bcc03ca1b97e686b210918b..5e30bf783b9b37bdc22b827c63e78df726da6b75 100644 (file)
@@ -8,6 +8,7 @@ export interface ActivityPubActor {
   id: string
   following: string
   followers: string
+  playlists?: string
   inbox: string
   outbox: string
   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 (file)
index 0000000..b85e4fe
--- /dev/null
@@ -0,0 +1,10 @@
+export interface PlaylistElementObject {
+  id: string
+  type: 'PlaylistElement'
+
+  url: string
+  position: number
+
+  startTimestamp?: number
+  stopTimestamp?: number
+}
diff --git a/shared/models/activitypub/objects/playlist-object.ts b/shared/models/activitypub/objects/playlist-object.ts
new file mode 100644 (file)
index 0000000..5f6733f
--- /dev/null
@@ -0,0 +1,23 @@
+import { ActivityIconObject } from './common-objects'
+
+export interface PlaylistObject {
+  id: string
+  type: 'Playlist'
+
+  name: string
+  content: string
+  uuid: string
+
+  totalItems: number
+  attributedTo: string[]
+
+  icon: ActivityIconObject
+
+  orderedItems?: string[]
+
+  partOf?: string
+  next?: string
+  first?: string
+
+  to?: string[]
+}
index 7f1dbbc37755e0392201da5219a40f19bda25e44..043a2507e388f8dab9408883c81ea04f6fe5c467 100644 (file)
@@ -1,4 +1,5 @@
 import { Actor } from './actor.model'
+import { Avatar } from '../avatars'
 
 export interface Account extends Actor {
   displayName: string
@@ -6,3 +7,13 @@ export interface Account extends Actor {
 
   userId?: number
 }
+
+export interface AccountSummary {
+  id: number
+  uuid: string
+  name: string
+  displayName: string
+  url: string
+  host: string
+  avatar?: Avatar
+}
index ee009d94c7fac8d395ed15299ae18b50add1e4a2..e725f166bbce01485ac05572c77b063c8bf91ffd 100644 (file)
@@ -1,8 +1,8 @@
-import { Video, VideoChannelAttribute, VideoConstant } from '../videos'
+import { Video, VideoChannelSummary, VideoConstant } from '../videos'
 
 export interface VideosOverview {
   channels: {
-    channel: VideoChannelAttribute
+    channel: VideoChannelSummary
     videos: Video[]
   }[]
 
index 090256bca0dd066fe41d9b019a1e7940b7f7ffa1..eaa064bd9836e2e586165d71e2aabf210d7f04d4 100644 (file)
@@ -20,8 +20,12 @@ export enum UserRight {
 
   REMOVE_ANY_VIDEO,
   REMOVE_ANY_VIDEO_CHANNEL,
+  REMOVE_ANY_VIDEO_PLAYLIST,
   REMOVE_ANY_VIDEO_COMMENT,
+
   UPDATE_ANY_VIDEO,
+  UPDATE_ANY_VIDEO_PLAYLIST,
+
   SEE_ALL_VIDEOS,
   CHANGE_VIDEO_OWNERSHIP
 }
index 59c2ba10656a63a896ca8a429c437ff0e367fe69..0b6554e5160cf4d470473d6b8ec3121c95e83ef3 100644 (file)
@@ -25,6 +25,7 @@ const userRoleRights: { [ id: number ]: UserRight[] } = {
     UserRight.MANAGE_VIDEO_ABUSES,
     UserRight.REMOVE_ANY_VIDEO,
     UserRight.REMOVE_ANY_VIDEO_CHANNEL,
+    UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
     UserRight.REMOVE_ANY_VIDEO_COMMENT,
     UserRight.UPDATE_ANY_VIDEO,
     UserRight.SEE_ALL_VIDEOS,
index 92918f66cae1133a447b7faec249fe8331b858d1..14a813f8f752eea00d660064c90c5261a4c8fa4a 100644 (file)
@@ -1,6 +1,6 @@
 import { Actor } from '../../actors/actor.model'
-import { Video } from '../video.model'
 import { Account } from '../../actors/index'
+import { Avatar } from '../../avatars'
 
 export interface VideoChannel extends Actor {
   displayName: string
@@ -9,3 +9,13 @@ export interface VideoChannel extends Actor {
   isLocal: boolean
   ownerAccount?: Account
 }
+
+export interface VideoChannelSummary {
+  id: number
+  uuid: string
+  name: string
+  displayName: string
+  url: string
+  host: string
+  avatar?: Avatar
+}
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 (file)
index 0000000..386acbb
--- /dev/null
@@ -0,0 +1,11 @@
+import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
+
+export interface VideoPlaylistCreate {
+  displayName: string
+  description: string
+  privacy: VideoPlaylistPrivacy
+
+  videoChannelId?: number
+
+  thumbnailfile?: Blob
+}
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 (file)
index 0000000..9bd56a8
--- /dev/null
@@ -0,0 +1,4 @@
+export interface VideoPlaylistElementCreate {
+  startTimestamp?: number
+  stopTimestamp?: number
+}
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 (file)
index 0000000..15a30fb
--- /dev/null
@@ -0,0 +1,4 @@
+export interface VideoPlaylistElementUpdate {
+  startTimestamp?: number
+  stopTimestamp?: number
+}
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 (file)
index 0000000..96e5e22
--- /dev/null
@@ -0,0 +1,5 @@
+export enum VideoPlaylistPrivacy {
+  PUBLIC = 1,
+  UNLISTED = 2,
+  PRIVATE = 3
+}
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 (file)
index 0000000..c7a15c5
--- /dev/null
@@ -0,0 +1,10 @@
+import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
+
+export interface VideoPlaylistUpdate {
+  displayName: string
+  description: string
+  privacy: VideoPlaylistPrivacy
+
+  videoChannelId?: number
+  thumbnailfile?: Blob
+}
diff --git a/shared/models/videos/playlist/video-playlist.model.ts b/shared/models/videos/playlist/video-playlist.model.ts
new file mode 100644 (file)
index 0000000..6aa0404
--- /dev/null
@@ -0,0 +1,23 @@
+import { AccountSummary } from '../../actors/index'
+import { VideoChannelSummary, VideoConstant } from '..'
+import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
+
+export interface VideoPlaylist {
+  id: number
+  uuid: string
+  isLocal: boolean
+
+  displayName: string
+  description: string
+  privacy: VideoConstant<VideoPlaylistPrivacy>
+
+  thumbnailPath: string
+
+  videosLength: number
+
+  createdAt: Date | string
+  updatedAt: Date | string
+
+  ownerAccount?: AccountSummary
+  videoChannel?: VideoChannelSummary
+}
index df800461c0b7095e4a58ca8d4a510c4926ffb57c..6e7a6831ebf24969e136ad721af11c0d6a753bf6 100644 (file)
@@ -1,4 +1,4 @@
-import { VideoResolution, VideoState } from '../../index'
+import { AccountSummary, VideoChannelSummary, VideoResolution, VideoState } from '../../index'
 import { Account } from '../actors'
 import { Avatar } from '../avatars/avatar.model'
 import { VideoChannel } from './channel/video-channel.model'
@@ -18,26 +18,6 @@ export interface VideoFile {
   fps: number
 }
 
-export interface VideoChannelAttribute {
-  id: number
-  uuid: string
-  name: string
-  displayName: string
-  url: string
-  host: string
-  avatar?: Avatar
-}
-
-export interface AccountAttribute {
-  id: number
-  uuid: string
-  name: string
-  displayName: string
-  url: string
-  host: string
-  avatar?: Avatar
-}
-
 export interface Video {
   id: number
   uuid: string
@@ -68,12 +48,18 @@ export interface Video {
   blacklisted?: boolean
   blacklistedReason?: string
 
-  account: AccountAttribute
-  channel: VideoChannelAttribute
+  account: AccountSummary
+  channel: VideoChannelSummary
 
   userHistory?: {
     currentTime: number
   }
+
+  playlistElement?: {
+    position: number
+    startTimestamp: number
+    stopTimestamp: number
+  }
 }
 
 export interface VideoDetails extends Video {
index eb25011cbcb3a5381841c8f4b27c5454e8ea046e..5186d9c4f70512c3e89c2d4682867ad392233f75 100644 (file)
-import { makeRawRequest } from '../requests/requests'
-import { sha256 } from '../../../server/helpers/core-utils'
-import { VideoStreamingPlaylist } from '../../models/videos/video-streaming-playlist.model'
-import { expect } from 'chai'
+import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest, makeUploadRequest } from '../requests/requests'
+import { VideoPlaylistCreate } from '../../models/videos/playlist/video-playlist-create.model'
+import { omit } from 'lodash'
+import { VideoPlaylistUpdate } from '../../models/videos/playlist/video-playlist-update.model'
+import { VideoPlaylistElementCreate } from '../../models/videos/playlist/video-playlist-element-create.model'
+import { VideoPlaylistElementUpdate } from '../../models/videos/playlist/video-playlist-element-update.model'
 
-function getPlaylist (url: string, statusCodeExpected = 200) {
-  return makeRawRequest(url, statusCodeExpected)
+function getVideoPlaylistsList (url: string, start: number, count: number, sort?: string) {
+  const path = '/api/v1/video-playlists'
+
+  const query = {
+    start,
+    count,
+    sort
+  }
+
+  return makeGetRequest({
+    url,
+    path,
+    query
+  })
 }
 
-function getSegment (url: string, statusCodeExpected = 200, range?: string) {
-  return makeRawRequest(url, statusCodeExpected, range)
+function getVideoPlaylist (url: string, playlistId: number | string, statusCodeExpected = 200) {
+  const path = '/api/v1/video-playlists/' + playlistId
+
+  return makeGetRequest({
+    url,
+    path,
+    statusCodeExpected
+  })
 }
 
-function getSegmentSha256 (url: string, statusCodeExpected = 200) {
-  return makeRawRequest(url, statusCodeExpected)
+function deleteVideoPlaylist (url: string, token: string, playlistId: number | string, statusCodeExpected = 200) {
+  const path = '/api/v1/video-playlists/' + playlistId
+
+  return makeDeleteRequest({
+    url,
+    path,
+    token,
+    statusCodeExpected
+  })
 }
 
-async function checkSegmentHash (
-  baseUrlPlaylist: string,
-  baseUrlSegment: string,
-  videoUUID: string,
-  resolution: number,
-  hlsPlaylist: VideoStreamingPlaylist
-) {
-  const res = await getPlaylist(`${baseUrlPlaylist}/${videoUUID}/${resolution}.m3u8`)
-  const playlist = res.text
+function createVideoPlaylist (options: {
+  url: string,
+  token: string,
+  playlistAttrs: VideoPlaylistCreate,
+  expectedStatus: number
+}) {
+  const path = '/api/v1/video-playlists/'
+
+  const fields = omit(options.playlistAttrs, 'thumbnailfile')
 
-  const videoName = `${videoUUID}-${resolution}-fragmented.mp4`
+  const attaches = options.playlistAttrs.thumbnailfile
+    ? { thumbnailfile: options.playlistAttrs.thumbnailfile }
+    : {}
 
-  const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist)
+  return makeUploadRequest({
+    method: 'POST',
+    url: options.url,
+    path,
+    token: options.token,
+    fields,
+    attaches,
+    statusCodeExpected: options.expectedStatus
+  })
+}
 
-  const length = parseInt(matches[1], 10)
-  const offset = parseInt(matches[2], 10)
-  const range = `${offset}-${offset + length - 1}`
+function updateVideoPlaylist (options: {
+  url: string,
+  token: string,
+  playlistAttrs: VideoPlaylistUpdate,
+  expectedStatus: number
+}) {
+  const path = '/api/v1/video-playlists/'
 
-  const res2 = await getSegment(`${baseUrlSegment}/${videoUUID}/${videoName}`, 206, `bytes=${range}`)
+  const fields = omit(options.playlistAttrs, 'thumbnailfile')
 
-  const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url)
+  const attaches = options.playlistAttrs.thumbnailfile
+    ? { thumbnailfile: options.playlistAttrs.thumbnailfile }
+    : {}
 
-  const sha256Server = resSha.body[ videoName ][range]
-  expect(sha256(res2.body)).to.equal(sha256Server)
+  return makeUploadRequest({
+    method: 'PUT',
+    url: options.url,
+    path,
+    token: options.token,
+    fields,
+    attaches,
+    statusCodeExpected: options.expectedStatus
+  })
+}
+
+function addVideoInPlaylist (options: {
+  url: string,
+  token: string,
+  playlistId: number | string,
+  elementAttrs: VideoPlaylistElementCreate
+  expectedStatus: number
+}) {
+  const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
+
+  return makePostBodyRequest({
+    url: options.url,
+    path,
+    token: options.token,
+    fields: options.elementAttrs,
+    statusCodeExpected: options.expectedStatus
+  })
+}
+
+function updateVideoPlaylistElement (options: {
+  url: string,
+  token: string,
+  playlistId: number | string,
+  videoId: number | string,
+  elementAttrs: VideoPlaylistElementUpdate,
+  expectedStatus: number
+}) {
+  const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId
+
+  return makePutBodyRequest({
+    url: options.url,
+    path,
+    token: options.token,
+    fields: options.elementAttrs,
+    statusCodeExpected: options.expectedStatus
+  })
+}
+
+function removeVideoFromPlaylist (options: {
+  url: string,
+  token: string,
+  playlistId: number | string,
+  videoId: number | string,
+  expectedStatus: number
+}) {
+  const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId
+
+  return makeDeleteRequest({
+    url: options.url,
+    path,
+    token: options.token,
+    statusCodeExpected: options.expectedStatus
+  })
+}
+
+function reorderVideosPlaylist (options: {
+  url: string,
+  token: string,
+  playlistId: number | string,
+  elementAttrs: {
+    startPosition: number,
+    insertAfter: number,
+    reorderLength?: number
+  },
+  expectedStatus: number
+}) {
+  const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
+
+  return makePutBodyRequest({
+    url: options.url,
+    path,
+    token: options.token,
+    fields: options.elementAttrs,
+    statusCodeExpected: options.expectedStatus
+  })
 }
 
 // ---------------------------------------------------------------------------
 
 export {
-  getPlaylist,
-  getSegment,
-  getSegmentSha256,
-  checkSegmentHash
+  getVideoPlaylistsList,
+  getVideoPlaylist,
+
+  createVideoPlaylist,
+  updateVideoPlaylist,
+  deleteVideoPlaylist,
+
+  addVideoInPlaylist,
+  removeVideoFromPlaylist,
+
+  reorderVideosPlaylist
 }
diff --git a/shared/utils/videos/video-streaming-playlists.ts b/shared/utils/videos/video-streaming-playlists.ts
new file mode 100644 (file)
index 0000000..eb25011
--- /dev/null
@@ -0,0 +1,51 @@
+import { makeRawRequest } from '../requests/requests'
+import { sha256 } from '../../../server/helpers/core-utils'
+import { VideoStreamingPlaylist } from '../../models/videos/video-streaming-playlist.model'
+import { expect } from 'chai'
+
+function getPlaylist (url: string, statusCodeExpected = 200) {
+  return makeRawRequest(url, statusCodeExpected)
+}
+
+function getSegment (url: string, statusCodeExpected = 200, range?: string) {
+  return makeRawRequest(url, statusCodeExpected, range)
+}
+
+function getSegmentSha256 (url: string, statusCodeExpected = 200) {
+  return makeRawRequest(url, statusCodeExpected)
+}
+
+async function checkSegmentHash (
+  baseUrlPlaylist: string,
+  baseUrlSegment: string,
+  videoUUID: string,
+  resolution: number,
+  hlsPlaylist: VideoStreamingPlaylist
+) {
+  const res = await getPlaylist(`${baseUrlPlaylist}/${videoUUID}/${resolution}.m3u8`)
+  const playlist = res.text
+
+  const videoName = `${videoUUID}-${resolution}-fragmented.mp4`
+
+  const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist)
+
+  const length = parseInt(matches[1], 10)
+  const offset = parseInt(matches[2], 10)
+  const range = `${offset}-${offset + length - 1}`
+
+  const res2 = await getSegment(`${baseUrlSegment}/${videoUUID}/${videoName}`, 206, `bytes=${range}`)
+
+  const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url)
+
+  const sha256Server = resSha.body[ videoName ][range]
+  expect(sha256(res2.body)).to.equal(sha256Server)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  getPlaylist,
+  getSegment,
+  getSegmentSha256,
+  checkSegmentHash
+}
index b3d24bc53dd136389c43b3ccbe124f43e920fd56..2c09f008604556cdc226a79f37047b4f33fe9545 100644 (file)
@@ -223,6 +223,28 @@ function getVideoChannelVideos (
   })
 }
 
+function getPlaylistVideos (
+  url: string,
+  accessToken: string,
+  playlistId: number | string,
+  start: number,
+  count: number,
+  query: { nsfw?: boolean } = {}
+) {
+  const path = '/api/v1/video-playlists/' + playlistId + '/videos'
+
+  return makeGetRequest({
+    url,
+    path,
+    query: immutableAssign(query, {
+      start,
+      count
+    }),
+    token: accessToken,
+    statusCodeExpected: 200
+  })
+}
+
 function getVideosListPagination (url: string, start: number, count: number, sort?: string) {
   const path = '/api/v1/videos'
 
@@ -601,5 +623,6 @@ export {
   parseTorrentVideo,
   getLocalVideos,
   completeVideoCheck,
-  checkVideoFilesWereRemoved
+  checkVideoFilesWereRemoved,
+  getPlaylistVideos
 }