From 418d092afa81e2c8fe8ac6838fc4b5eb0af6a782 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 26 Feb 2019 10:55:40 +0100 Subject: Playlist server API --- server/controllers/activitypub/client.ts | 59 ++- server/controllers/activitypub/outbox.ts | 2 +- server/controllers/api/accounts.ts | 57 ++- server/controllers/api/index.ts | 2 + server/controllers/api/video-channel.ts | 27 +- server/controllers/api/video-playlist.ts | 415 +++++++++++++++++++++ server/controllers/api/videos/blacklist.ts | 4 +- server/controllers/services.ts | 4 +- server/helpers/activitypub.ts | 9 +- .../custom-validators/activitypub/activity.ts | 3 + .../custom-validators/activitypub/playlist.ts | 25 ++ .../helpers/custom-validators/video-playlists.ts | 44 +++ server/helpers/custom-validators/videos.ts | 2 +- server/initializers/constants.ts | 23 +- server/initializers/database.ts | 6 +- server/lib/activitypub/actor.ts | 13 +- server/lib/activitypub/cache-file.ts | 2 +- server/lib/activitypub/crawl.ts | 2 +- server/lib/activitypub/playlist.ts | 162 ++++++++ server/lib/activitypub/process/process-create.ts | 19 +- server/lib/activitypub/process/process-update.ts | 15 + server/lib/activitypub/send/send-create.ts | 23 ++ server/lib/activitypub/send/send-delete.ts | 21 +- server/lib/activitypub/send/send-update.ts | 30 +- server/lib/activitypub/url.ts | 12 + .../job-queue/handlers/activitypub-http-fetcher.ts | 11 +- server/middlewares/validators/account.ts | 4 +- server/middlewares/validators/sort.ts | 5 +- .../validators/videos/video-channels.ts | 14 - .../middlewares/validators/videos/video-imports.ts | 4 +- .../validators/videos/video-playlists.ts | 302 +++++++++++++++ server/middlewares/validators/videos/videos.ts | 8 +- server/models/account/account.ts | 60 ++- server/models/activitypub/actor-follow.ts | 2 +- server/models/activitypub/actor.ts | 5 + server/models/utils.ts | 21 +- server/models/video/video-channel.ts | 78 +++- server/models/video/video-format-utils.ts | 36 +- server/models/video/video-playlist-element.ts | 231 ++++++++++++ server/models/video/video-playlist.ts | 381 +++++++++++++++++++ server/models/video/video.ts | 127 +++---- server/tests/api/check-params/video-playlists.ts | 117 ++++++ server/tests/api/videos/video-playlists.ts | 161 ++++++++ 43 files changed, 2384 insertions(+), 164 deletions(-) create mode 100644 server/controllers/api/video-playlist.ts create mode 100644 server/helpers/custom-validators/activitypub/playlist.ts create mode 100644 server/helpers/custom-validators/video-playlists.ts create mode 100644 server/lib/activitypub/playlist.ts create mode 100644 server/middlewares/validators/videos/video-playlists.ts create mode 100644 server/models/video/video-playlist-element.ts create mode 100644 server/models/video/video-playlist.ts create mode 100644 server/tests/api/check-params/video-playlists.ts create mode 100644 server/tests/api/videos/video-playlists.ts (limited to 'server') diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 31c0a5fbd..59e6c8e9f 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -14,7 +14,7 @@ import { 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) diff --git a/server/controllers/activitypub/outbox.ts b/server/controllers/activitypub/outbox.ts index bd0e4fe9d..e060affb2 100644 --- a/server/controllers/activitypub/outbox.ts +++ b/server/controllers/activitypub/outbox.ts @@ -32,7 +32,7 @@ export { // --------------------------------------------------------------------------- -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' diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index 8c0237203..03c831092 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts @@ -1,21 +1,23 @@ 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 diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts index 8a58b5466..ed4b33dea 100644 --- a/server/controllers/api/index.ts +++ b/server/controllers/api/index.ts @@ -11,6 +11,7 @@ import { videoChannelRouter } from './video-channel' 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) diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index db7602139..534cc8d7b 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -12,7 +12,8 @@ import { 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 index 000000000..709c58beb --- /dev/null +++ b/server/controllers/api/video-playlist.ts @@ -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)) +} diff --git a/server/controllers/api/videos/blacklist.ts b/server/controllers/api/videos/blacklist.ts index 43b0516e7..b01296200 100644 --- a/server/controllers/api/videos/blacklist.ts +++ b/server/controllers/api/videos/blacklist.ts @@ -1,5 +1,5 @@ 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() diff --git a/server/controllers/services.ts b/server/controllers/services.ts index 352d0b19a..680c3c37f 100644 --- a/server/controllers/services.ts +++ b/server/controllers/services.ts @@ -1,7 +1,7 @@ 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 ) diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts index e850efe13..31c6187d1 100644 --- a/server/helpers/activitypub.ts +++ b/server/helpers/activitypub.ts @@ -28,6 +28,9 @@ function activityPubContextify (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 (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' } diff --git a/server/helpers/custom-validators/activitypub/activity.ts b/server/helpers/custom-validators/activitypub/activity.ts index b24590d9d..e0d170d9d 100644 --- a/server/helpers/custom-validators/activitypub/activity.ts +++ b/server/helpers/custom-validators/activitypub/activity.ts @@ -9,6 +9,7 @@ import { isViewActivityValid } from './view' 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 index 000000000..ecdc7975e --- /dev/null +++ b/server/helpers/custom-validators/activitypub/playlist.ts @@ -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 index 000000000..0f5af4ec0 --- /dev/null +++ b/server/helpers/custom-validators/video-playlists.ts @@ -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 +} diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index dd04aa5f6..d00d24c4c 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -165,7 +165,7 @@ function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: Use 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) diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 0d9a6a512..154a9cffe 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -10,6 +10,7 @@ import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' 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 } diff --git a/server/initializers/database.ts b/server/initializers/database.ts index fe296142d..541ebbecf 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -34,6 +34,8 @@ import { ServerBlocklistModel } from '../models/server/server-blocklist' 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 diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts index a3f379b76..f77df8b78 100644 --- a/server/lib/activitypub/actor.ts +++ b/server/lib/activitypub/actor.ts @@ -44,6 +44,7 @@ async function getOrCreateActorAndServerAndModel ( ) { 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 } } diff --git a/server/lib/activitypub/cache-file.ts b/server/lib/activitypub/cache-file.ts index 9a40414bb..597003135 100644 --- a/server/lib/activitypub/cache-file.ts +++ b/server/lib/activitypub/cache-file.ts @@ -1,4 +1,4 @@ -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' diff --git a/server/lib/activitypub/crawl.ts b/server/lib/activitypub/crawl.ts index 1b9b14c2e..2675524c6 100644 --- a/server/lib/activitypub/crawl.ts +++ b/server/lib/activitypub/crawl.ts @@ -4,7 +4,7 @@ import { logger } from '../../helpers/logger' import * as Bluebird from 'bluebird' import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub' -async function crawlCollectionPage (uri: string, handler: (items: T[]) => Promise | Bluebird) { +async function crawlCollectionPage (uri: string, handler: (items: T[]) => (Promise | Bluebird)) { 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 index 000000000..c9b428c92 --- /dev/null +++ b/server/lib/activitypub/playlist.ts @@ -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({ + 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(playlistAttributes, { returning: true }) + + let accItems: string[] = [] + await crawlCollectionPage(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[] = [] + + await Bluebird.map(elementUrls, async elementUrl => { + try { + // Fetch url + const { body } = await doRequest({ + 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) +} diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts index 5f4d793a5..e882669ce 100644 --- a/server/lib/activitypub/process/process-create.ts +++ b/server/lib/activitypub/process/process-create.ts @@ -12,6 +12,8 @@ import { Notifier } from '../../notifier' 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) +} diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts index c6b42d846..0b96ba352 100644 --- a/server/lib/activitypub/process/process-update.ts +++ b/server/lib/activitypub/process/process-update.ts @@ -12,6 +12,8 @@ import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-vali 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) +} diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts index ef20e404c..bacdb97e3 100644 --- a/server/lib/activitypub/send/send-create.ts +++ b/server/lib/activitypub/send/send-create.ts @@ -8,6 +8,9 @@ import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unic 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 } diff --git a/server/lib/activitypub/send/send-delete.ts b/server/lib/activitypub/send/send-delete.ts index 18969433a..016811e60 100644 --- a/server/lib/activitypub/send/send-delete.ts +++ b/server/lib/activitypub/send/send-delete.ts @@ -8,6 +8,8 @@ import { getDeleteActivityPubUrl } from '../url' 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 } // --------------------------------------------------------------------------- diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts index 839f66470..3eb2704fd 100644 --- a/server/lib/activitypub/send/send-update.ts +++ b/server/lib/activitypub/send/send-update.ts @@ -12,8 +12,13 @@ import { audiencify, getActorsInvolvedInVideo, getAudience } from '../audience' 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 } // --------------------------------------------------------------------------- diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts index 4229fe094..00bbbba2d 100644 --- a/server/lib/activitypub/url.ts +++ b/server/lib/activitypub/url.ts @@ -7,11 +7,21 @@ import { VideoCommentModel } from '../../models/video/video-comment' 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, diff --git a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts index 67ccfa995..52225f64f 100644 --- a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts +++ b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts @@ -5,13 +5,16 @@ import { addVideoComments } from '../../activitypub/video-comments' 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 } = { '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]) diff --git a/server/middlewares/validators/account.ts b/server/middlewares/validators/account.ts index b3a51e631..88c57eaa1 100644 --- a/server/middlewares/validators/account.ts +++ b/server/middlewares/validators/account.ts @@ -17,7 +17,7 @@ const localAccountValidator = [ } ] -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 } diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index 5ceda845f..ea59fbf73 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -19,6 +19,7 @@ const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUM 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 } diff --git a/server/middlewares/validators/videos/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts index f039794e0..c2763ce51 100644 --- a/server/middlewares/validators/videos/video-channels.ts +++ b/server/middlewares/validators/videos/video-channels.ts @@ -16,19 +16,6 @@ import { areValidationErrors } from '../utils' 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, diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts index 48d20f904..121df36b6 100644 --- a/server/middlewares/validators/videos/video-imports.ts +++ b/server/middlewares/validators/videos/video-imports.ts @@ -3,14 +3,14 @@ import { body } from 'express-validator/check' 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 index 000000000..ef8d0b851 --- /dev/null +++ b/server/middlewares/validators/videos/video-playlists.ts @@ -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 +} diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index 159727e28..a5e3ed0dc 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts @@ -46,7 +46,7 @@ import { VideoFetchType } from '../../../helpers/video' 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 } diff --git a/server/models/account/account.ts b/server/models/account/account.ts index ee22d8528..3fb766c8a 100644 --- a/server/models/account/account.ts +++ b/server/models/account/account.ts @@ -10,11 +10,11 @@ import { 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) => { + 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 { }) 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 { 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') diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts index 796e07a42..e3eeb7dae 100644 --- a/server/models/activitypub/actor-follow.ts +++ b/server/models/activitypub/actor-follow.ts @@ -407,7 +407,7 @@ export class ActorFollowModel extends Model { }) } - 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) } diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 49f82023b..2fceb21dd 100644 --- a/server/models/activitypub/actor.ts +++ b/server/models/activitypub/actor.ts @@ -444,6 +444,7 @@ export class ActorModel extends Model { 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 { return this.url + '/followers' } + getPlaylistsUrl () { + return this.url + '/playlists' + } + getPublicKeyUrl () { return this.url + '#main-key' } diff --git a/server/models/utils.ts b/server/models/utils.ts index 5b4093aec..4ebd07dab 100644 --- a/server/models/utils.ts +++ b/server/models/utils.ts @@ -1,4 +1,5 @@ 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 } // --------------------------------------------------------------------------- diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index 2426b3de6..112abf8cf 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts @@ -8,7 +8,7 @@ import { 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 = { + 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 { }) 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 { 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') diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index a62335333..dc10fb9a2 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts @@ -26,12 +26,10 @@ export type VideoFormattingJSONOptions = { 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 index 000000000..d76149d12 --- /dev/null +++ b/server/models/video/video-playlist-element.ts @@ -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 { + @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 index 000000000..93b8c2f58 --- /dev/null +++ b/server/models/video/video-playlist.ts @@ -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 { + @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 { + 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 + } + }) + }) + } +} diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 4516b9c7b..7a102b058 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -40,7 +40,7 @@ import { 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 = { 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 { }) 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 { accountId?: number, videoChannelId?: number, followerActorId?: number + videoPlaylistId?: number, trendingDays?: number, user?: UserModel, historyOfUser?: UserModel @@ -1157,6 +1158,7 @@ export class VideoModel extends Model { 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 { } 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 { } 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 { } 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 { } 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 { } 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 { 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 { } 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 index 000000000..7004badac --- /dev/null +++ b/server/tests/api/check-params/video-playlists.ts @@ -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 index 000000000..cb23239da --- /dev/null +++ b/server/tests/api/videos/video-playlists.ts @@ -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() + } + }) +}) -- cgit v1.2.3