From df0b219d36bf6852cdf2a7ad09ed4a41c6bccefa Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 5 Mar 2019 10:58:44 +0100 Subject: Add playlist rest tests --- server/controllers/activitypub/client.ts | 5 +- server/controllers/api/accounts.ts | 5 +- server/controllers/api/users/index.ts | 6 +- server/controllers/api/video-channel.ts | 7 +- server/controllers/api/video-playlist.ts | 25 +- .../custom-validators/activitypub/playlist.ts | 6 +- .../helpers/custom-validators/video-playlists.ts | 17 +- server/initializers/constants.ts | 7 + server/initializers/installer.ts | 4 +- server/lib/activitypub/playlist.ts | 4 +- server/lib/activitypub/process/process-delete.ts | 24 + server/lib/activitypub/send/send-create.ts | 2 +- server/lib/activitypub/send/send-delete.ts | 7 +- server/lib/activitypub/send/send-update.ts | 4 +- server/lib/user.ts | 9 +- server/lib/video-playlist.ts | 29 + .../validators/videos/video-playlists.ts | 48 +- server/models/video/video-channel.ts | 6 +- server/models/video/video-playlist-element.ts | 10 +- server/models/video/video-playlist.ts | 92 ++- server/models/video/video-share.ts | 2 +- server/models/video/video.ts | 38 +- server/tests/api/check-params/index.ts | 1 + server/tests/api/check-params/video-playlists.ts | 818 +++++++-------------- server/tests/api/videos/video-playlists.ts | 728 ++++++++++++++++-- server/tests/fixtures/thumbnail-playlist.jpg | Bin 0 -> 2520 bytes 26 files changed, 1235 insertions(+), 669 deletions(-) create mode 100644 server/lib/video-playlist.ts create mode 100644 server/tests/fixtures/thumbnail-playlist.jpg (limited to 'server') diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 59e6c8e9f..f616047b0 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -320,7 +320,10 @@ async function videoRedundancyController (req: express.Request, res: express.Res async function videoPlaylistController (req: express.Request, res: express.Response) { const playlist: VideoPlaylistModel = res.locals.videoPlaylist - const json = await playlist.toActivityPubObject() + // We need more attributes + playlist.OwnerAccount = await AccountModel.load(playlist.ownerAccountId) + + const json = await playlist.toActivityPubObject(req.query.page, null) const audience = getAudience(playlist.OwnerAccount.Actor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC) const object = audiencify(json, audience) diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index 03c831092..e24545de8 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts @@ -18,6 +18,7 @@ import { JobQueue } from '../../lib/job-queue' import { logger } from '../../helpers/logger' import { VideoPlaylistModel } from '../../models/video/video-playlist' import { UserModel } from '../../models/account/user' +import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' const accountsRouter = express.Router() @@ -57,6 +58,7 @@ accountsRouter.get('/:accountName/video-playlists', videoPlaylistsSortValidator, setDefaultSort, setDefaultPagination, + commonVideoPlaylistFiltersValidator, asyncMiddleware(listAccountPlaylists) ) @@ -106,7 +108,8 @@ async function listAccountPlaylists (req: express.Request, res: express.Response count: req.query.count, sort: req.query.sort, accountId: res.locals.account.id, - privateAndUnlisted + privateAndUnlisted, + type: req.query.playlistType }) return res.json(getFormattedObjects(resultList.data, resultList.total)) diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 407f32ac0..5758c8227 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -6,7 +6,7 @@ import { getFormattedObjects } from '../../../helpers/utils' import { CONFIG, RATES_LIMIT, sequelizeTypescript } from '../../../initializers' import { Emailer } from '../../../lib/emailer' import { Redis } from '../../../lib/redis' -import { createUserAccountAndChannel } from '../../../lib/user' +import { createUserAccountAndChannelAndPlaylist } from '../../../lib/user' import { asyncMiddleware, asyncRetryTransactionMiddleware, @@ -174,7 +174,7 @@ async function createUser (req: express.Request, res: express.Response) { videoQuotaDaily: body.videoQuotaDaily }) - const { user, account } = await createUserAccountAndChannel(userToCreate) + const { user, account } = await createUserAccountAndChannelAndPlaylist(userToCreate) auditLogger.create(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON())) logger.info('User %s with its channel and account created.', body.username) @@ -205,7 +205,7 @@ async function registerUser (req: express.Request, res: express.Response) { emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null }) - const { user } = await createUserAccountAndChannel(userToCreate) + const { user } = await createUserAccountAndChannelAndPlaylist(userToCreate) auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON())) logger.info('User %s with its channel and account registered.', body.username) diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index 534cc8d7b..c13aed4dc 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -33,6 +33,7 @@ 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' +import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' const auditLogger = auditLoggerFactory('channels') const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR }) @@ -85,6 +86,7 @@ videoChannelRouter.get('/:nameWithHost/video-playlists', videoPlaylistsSortValidator, setDefaultSort, setDefaultPagination, + commonVideoPlaylistFiltersValidator, asyncMiddleware(listVideoChannelPlaylists) ) @@ -197,6 +199,8 @@ async function removeVideoChannel (req: express.Request, res: express.Response) const videoChannelInstance: VideoChannelModel = res.locals.videoChannel await sequelizeTypescript.transaction(async t => { + await VideoPlaylistModel.resetPlaylistsOfChannel(videoChannelInstance.id, t) + await videoChannelInstance.destroy({ transaction: t }) auditLogger.delete(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannelInstance.toFormattedJSON())) @@ -225,7 +229,8 @@ async function listVideoChannelPlaylists (req: express.Request, res: express.Res start: req.query.start, count: req.query.count, sort: req.query.sort, - videoChannelId: res.locals.videoChannel.id + videoChannelId: res.locals.videoChannel.id, + type: req.query.playlistType }) return res.json(getFormattedObjects(resultList.data, resultList.total)) diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts index e026b4d16..8605f3dfc 100644 --- a/server/controllers/api/video-playlist.ts +++ b/server/controllers/api/video-playlist.ts @@ -17,6 +17,7 @@ import { logger } from '../../helpers/logger' import { resetSequelizeInstance } from '../../helpers/database-utils' import { VideoPlaylistModel } from '../../models/video/video-playlist' import { + commonVideoPlaylistFiltersValidator, videoPlaylistsAddValidator, videoPlaylistsAddVideoValidator, videoPlaylistsDeleteValidator, @@ -45,6 +46,7 @@ import { VideoPlaylistElementModel } from '../../models/video/video-playlist-ele 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' +import { AccountModel } from '../../models/account/account' const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) @@ -55,6 +57,7 @@ videoPlaylistRouter.get('/', videoPlaylistsSortValidator, setDefaultSort, setDefaultPagination, + commonVideoPlaylistFiltersValidator, asyncMiddleware(listVideoPlaylists) ) @@ -130,7 +133,8 @@ async function listVideoPlaylists (req: express.Request, res: express.Response) followerActorId: serverActor.id, start: req.query.start, count: req.query.count, - sort: req.query.sort + sort: req.query.sort, + type: req.query.type }) return res.json(getFormattedObjects(resultList.data, resultList.total)) @@ -171,7 +175,8 @@ async function addVideoPlaylist (req: express.Request, res: express.Response) { const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => { const videoPlaylistCreated = await videoPlaylist.save({ transaction: t }) - videoPlaylistCreated.OwnerAccount = user.Account + // We need more attributes for the federation + videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t) await sendCreateVideoPlaylist(videoPlaylistCreated, t) return videoPlaylistCreated @@ -216,6 +221,7 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response) const videoChannel = res.locals.videoChannel as VideoChannelModel videoPlaylistInstance.videoChannelId = videoChannel.id + videoPlaylistInstance.VideoChannel = videoChannel } } @@ -227,6 +233,8 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response) } const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions) + // We need more attributes for the federation + playlistUpdated.OwnerAccount = await AccountModel.load(playlistUpdated.OwnerAccount.id, t) const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE @@ -290,11 +298,15 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response) const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()) if (await pathExists(playlistThumbnailPath) === false) { + logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url) + const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()) await copy(videoThumbnailPath, playlistThumbnailPath) } } + // We need more attributes for the federation + videoPlaylist.OwnerAccount = await AccountModel.load(videoPlaylist.OwnerAccount.id, t) await sendUpdateVideoPlaylist(videoPlaylist, t) return playlistElement @@ -320,6 +332,8 @@ async function updateVideoPlaylistElement (req: express.Request, res: express.Re const element = await videoPlaylistElement.save({ transaction: t }) + // We need more attributes for the federation + videoPlaylist.OwnerAccount = await AccountModel.load(videoPlaylist.OwnerAccount.id, t) await sendUpdateVideoPlaylist(videoPlaylist, t) return element @@ -341,6 +355,8 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo // Decrease position of the next elements await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t) + // We need more attributes for the federation + videoPlaylist.OwnerAccount = await AccountModel.load(videoPlaylist.OwnerAccount.id, t) await sendUpdateVideoPlaylist(videoPlaylist, t) logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid) @@ -382,6 +398,8 @@ async function reorderVideosPlaylist (req: express.Request, res: express.Respons // Decrease positions of elements after the old position of our ordered elements (decrease) await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t) + // We need more attributes for the federation + videoPlaylist.OwnerAccount = await AccountModel.load(videoPlaylist.OwnerAccount.id, t) await sendUpdateVideoPlaylist(videoPlaylist, t) }) @@ -415,5 +433,6 @@ async function getVideoPlaylistVideos (req: express.Request, res: express.Respon user: res.locals.oauth ? res.locals.oauth.token.User : undefined }) - return res.json(getFormattedObjects(resultList.data, resultList.total)) + const additionalAttributes = { playlistInfo: true } + return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes })) } diff --git a/server/helpers/custom-validators/activitypub/playlist.ts b/server/helpers/custom-validators/activitypub/playlist.ts index ecdc7975e..6c7bdb193 100644 --- a/server/helpers/custom-validators/activitypub/playlist.ts +++ b/server/helpers/custom-validators/activitypub/playlist.ts @@ -1,4 +1,4 @@ -import { exists } from '../misc' +import { exists, isDateValid } 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' @@ -7,7 +7,9 @@ import { isActivityPubUrlValid } from './misc' function isPlaylistObjectValid (object: PlaylistObject) { return exists(object) && object.type === 'Playlist' && - validator.isInt(object.totalItems + '') + validator.isInt(object.totalItems + '') && + isDateValid(object.published) && + isDateValid(object.updated) } function isPlaylistElementObjectValid (object: PlaylistElementObject) { diff --git a/server/helpers/custom-validators/video-playlists.ts b/server/helpers/custom-validators/video-playlists.ts index 0f5af4ec0..c217a39bf 100644 --- a/server/helpers/custom-validators/video-playlists.ts +++ b/server/helpers/custom-validators/video-playlists.ts @@ -1,9 +1,8 @@ import { exists } from './misc' import * as validator from 'validator' -import { CONSTRAINTS_FIELDS, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers' +import { CONSTRAINTS_FIELDS, VIDEO_PLAYLIST_PRIVACIES, VIDEO_PLAYLIST_TYPES } 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 @@ -19,8 +18,16 @@ function isVideoPlaylistPrivacyValid (value: number) { return validator.isInt(value + '') && VIDEO_PLAYLIST_PRIVACIES[ value ] !== undefined } +function isVideoPlaylistTimestampValid (value: any) { + return value === null || (exists(value) && validator.isInt('' + value, { min: 0 })) +} + +function isVideoPlaylistTypeValid (value: any) { + return exists(value) && VIDEO_PLAYLIST_TYPES[ value ] !== undefined +} + async function isVideoPlaylistExist (id: number | string, res: express.Response) { - const videoPlaylist = await VideoPlaylistModel.load(id, undefined) + const videoPlaylist = await VideoPlaylistModel.loadWithAccountAndChannel(id, undefined) if (!videoPlaylist) { res.status(404) @@ -40,5 +47,7 @@ export { isVideoPlaylistExist, isVideoPlaylistNameValid, isVideoPlaylistDescriptionValid, - isVideoPlaylistPrivacyValid + isVideoPlaylistPrivacyValid, + isVideoPlaylistTimestampValid, + isVideoPlaylistTypeValid } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 154a9cffe..59c30fdee 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -11,6 +11,7 @@ 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' +import { VideoPlaylistType } from '../../shared/models/videos/playlist/video-playlist-type.model' // Use a variable to reload the configuration if we need let config: IConfig = require('config') @@ -522,6 +523,11 @@ const VIDEO_PLAYLIST_PRIVACIES = { [VideoPlaylistPrivacy.PRIVATE]: 'Private' } +const VIDEO_PLAYLIST_TYPES = { + [VideoPlaylistType.REGULAR]: 'Regular', + [VideoPlaylistType.WATCH_LATER]: 'Watch later' +} + const MIMETYPES = { VIDEO: { MIMETYPE_EXT: buildVideoMimetypeExt(), @@ -778,6 +784,7 @@ export { STATIC_MAX_AGE, STATIC_PATHS, VIDEO_IMPORT_TIMEOUT, + VIDEO_PLAYLIST_TYPES, ACTIVITY_PUB, ACTIVITY_PUB_ACTOR_TYPES, THUMBNAILS_SIZE, diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts index 2b22e16fe..c669606f8 100644 --- a/server/initializers/installer.ts +++ b/server/initializers/installer.ts @@ -1,7 +1,7 @@ import * as passwordGenerator from 'password-generator' import { UserRole } from '../../shared' import { logger } from '../helpers/logger' -import { createApplicationActor, createUserAccountAndChannel } from '../lib/user' +import { createApplicationActor, createUserAccountAndChannelAndPlaylist } from '../lib/user' import { UserModel } from '../models/account/user' import { ApplicationModel } from '../models/application/application' import { OAuthClientModel } from '../models/oauth/oauth-client' @@ -141,7 +141,7 @@ async function createOAuthAdminIfNotExist () { } const user = new UserModel(userData) - await createUserAccountAndChannel(user, validatePassword) + await createUserAccountAndChannelAndPlaylist(user, validatePassword) logger.info('Username: ' + username) logger.info('User password: ' + password) } diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts index c9b428c92..70389044e 100644 --- a/server/lib/activitypub/playlist.ts +++ b/server/lib/activitypub/playlist.ts @@ -28,7 +28,9 @@ function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount url: playlistObject.id, uuid: playlistObject.uuid, ownerAccountId: byAccount.id, - videoChannelId: null + videoChannelId: null, + createdAt: new Date(playlistObject.published), + updatedAt: new Date(playlistObject.updated) } } diff --git a/server/lib/activitypub/process/process-delete.ts b/server/lib/activitypub/process/process-delete.ts index 155d2ffcc..76f07fd8a 100644 --- a/server/lib/activitypub/process/process-delete.ts +++ b/server/lib/activitypub/process/process-delete.ts @@ -8,6 +8,7 @@ import { VideoModel } from '../../../models/video/video' import { VideoChannelModel } from '../../../models/video/video-channel' import { VideoCommentModel } from '../../../models/video/video-comment' import { forwardVideoRelatedActivity } from '../send/utils' +import { VideoPlaylistModel } from '../../../models/video/video-playlist' async function processDeleteActivity (activity: ActivityDelete, byActor: ActorModel) { const objectUrl = typeof activity.object === 'string' ? activity.object : activity.object.id @@ -45,6 +46,15 @@ async function processDeleteActivity (activity: ActivityDelete, byActor: ActorMo } } + { + const videoPlaylist = await VideoPlaylistModel.loadByUrlAndPopulateAccount(objectUrl) + if (videoPlaylist) { + if (videoPlaylist.isOwned()) throw new Error(`Remote instance cannot delete owned playlist ${videoPlaylist.url}.`) + + return retryTransactionWrapper(processDeleteVideoPlaylist, byActor, videoPlaylist) + } + } + return undefined } @@ -70,6 +80,20 @@ async function processDeleteVideo (actor: ActorModel, videoToDelete: VideoModel) logger.info('Remote video with uuid %s removed.', videoToDelete.uuid) } +async function processDeleteVideoPlaylist (actor: ActorModel, playlistToDelete: VideoPlaylistModel) { + logger.debug('Removing remote video playlist "%s".', playlistToDelete.uuid) + + await sequelizeTypescript.transaction(async t => { + if (playlistToDelete.OwnerAccount.Actor.id !== actor.id) { + throw new Error('Account ' + actor.url + ' does not own video playlist ' + playlistToDelete.url) + } + + await playlistToDelete.destroy({ transaction: t }) + }) + + logger.info('Remote video playlist with uuid %s removed.', playlistToDelete.uuid) +} + async function processDeleteAccount (accountToRemove: AccountModel) { logger.debug('Removing remote account "%s".', accountToRemove.Actor.uuid) diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts index bacdb97e3..28f18595b 100644 --- a/server/lib/activitypub/send/send-create.ts +++ b/server/lib/activitypub/send/send-create.ts @@ -45,7 +45,7 @@ async function sendCreateVideoPlaylist (playlist: VideoPlaylistModel, t: Transac const byActor = playlist.OwnerAccount.Actor const audience = getAudience(byActor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC) - const object = await playlist.toActivityPubObject() + const object = await playlist.toActivityPubObject(null, t) const createActivity = buildCreateActivity(playlist.url, byActor, object, audience) const serverActor = await getServerActor() diff --git a/server/lib/activitypub/send/send-delete.ts b/server/lib/activitypub/send/send-delete.ts index 016811e60..7bf5ca520 100644 --- a/server/lib/activitypub/send/send-delete.ts +++ b/server/lib/activitypub/send/send-delete.ts @@ -31,7 +31,12 @@ async function sendDeleteActor (byActor: ActorModel, t: Transaction) { const url = getDeleteActivityPubUrl(byActor.url) const activity = buildDeleteActivity(url, byActor.url, byActor) - const actorsInvolved = await VideoShareModel.loadActorsByVideoOwner(byActor.id, t) + const actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(byActor.id, t) + + // In case the actor did not have any videos + const serverActor = await getServerActor() + actorsInvolved.push(serverActor) + actorsInvolved.push(byActor) return broadcastToFollowers(activity, byActor, actorsInvolved, t) diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts index 3eb2704fd..7411c08d5 100644 --- a/server/lib/activitypub/send/send-update.ts +++ b/server/lib/activitypub/send/send-update.ts @@ -52,7 +52,7 @@ async function sendUpdateActor (accountOrChannel: AccountModel | VideoChannelMod let actorsInvolved: ActorModel[] if (accountOrChannel instanceof AccountModel) { // Actors that shared my videos are involved too - actorsInvolved = await VideoShareModel.loadActorsByVideoOwner(byActor.id, t) + actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(byActor.id, t) } else { // Actors that shared videos of my channel are involved too actorsInvolved = await VideoShareModel.loadActorsByVideoChannel(accountOrChannel.id, t) @@ -87,7 +87,7 @@ async function sendUpdateVideoPlaylist (videoPlaylist: VideoPlaylistModel, t: Tr const url = getUpdateActivityPubUrl(videoPlaylist.url, videoPlaylist.updatedAt.toISOString()) - const object = await videoPlaylist.toActivityPubObject() + const object = await videoPlaylist.toActivityPubObject(null, t) const audience = getAudience(byActor, videoPlaylist.privacy === VideoPlaylistPrivacy.PUBLIC) const updateActivity = buildUpdateActivity(url, byActor, object, audience) diff --git a/server/lib/user.ts b/server/lib/user.ts index a39ef6c3d..02a84f15b 100644 --- a/server/lib/user.ts +++ b/server/lib/user.ts @@ -11,8 +11,9 @@ import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' import { ActorModel } from '../models/activitypub/actor' import { UserNotificationSettingModel } from '../models/account/user-notification-setting' import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users' +import { createWatchLaterPlaylist } from './video-playlist' -async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) { +async function createUserAccountAndChannelAndPlaylist (userToCreate: UserModel, validateUser = true) { const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => { const userOptions = { transaction: t, @@ -38,7 +39,9 @@ async function createUserAccountAndChannel (userToCreate: UserModel, validateUse } const videoChannel = await createVideoChannel(videoChannelInfo, accountCreated, t) - return { user: userCreated, account: accountCreated, videoChannel } + const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t) + + return { user: userCreated, account: accountCreated, videoChannel, videoPlaylist } }) const [ accountKeys, channelKeys ] = await Promise.all([ @@ -89,7 +92,7 @@ async function createApplicationActor (applicationId: number) { export { createApplicationActor, - createUserAccountAndChannel, + createUserAccountAndChannelAndPlaylist, createLocalAccountWithoutKeys } diff --git a/server/lib/video-playlist.ts b/server/lib/video-playlist.ts new file mode 100644 index 000000000..6e214e60f --- /dev/null +++ b/server/lib/video-playlist.ts @@ -0,0 +1,29 @@ +import * as Sequelize from 'sequelize' +import { AccountModel } from '../models/account/account' +import { VideoPlaylistModel } from '../models/video/video-playlist' +import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model' +import { getVideoPlaylistActivityPubUrl } from './activitypub' +import { VideoPlaylistType } from '../../shared/models/videos/playlist/video-playlist-type.model' + +async function createWatchLaterPlaylist (account: AccountModel, t: Sequelize.Transaction) { + const videoPlaylist = new VideoPlaylistModel({ + name: 'Watch later', + privacy: VideoPlaylistPrivacy.PRIVATE, + type: VideoPlaylistType.WATCH_LATER, + ownerAccountId: account.id + }) + + videoPlaylist.url = getVideoPlaylistActivityPubUrl(videoPlaylist) // We use the UUID, so set the URL after building the object + + await videoPlaylist.save({ transaction: t }) + + videoPlaylist.OwnerAccount = account + + return videoPlaylist +} + +// --------------------------------------------------------------------------- + +export { + createWatchLaterPlaylist +} diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts index 0e97c8dc0..796c63748 100644 --- a/server/middlewares/validators/videos/video-playlists.ts +++ b/server/middlewares/validators/videos/video-playlists.ts @@ -1,6 +1,6 @@ import * as express from 'express' -import { body, param, ValidationChain } from 'express-validator/check' -import { UserRight, VideoPrivacy } from '../../../../shared' +import { body, param, query, ValidationChain } from 'express-validator/check' +import { UserRight } from '../../../../shared' import { logger } from '../../../helpers/logger' import { UserModel } from '../../../models/account/user' import { areValidationErrors } from '../utils' @@ -11,7 +11,9 @@ import { isVideoPlaylistDescriptionValid, isVideoPlaylistExist, isVideoPlaylistNameValid, - isVideoPlaylistPrivacyValid + isVideoPlaylistPrivacyValid, + isVideoPlaylistTimestampValid, + isVideoPlaylistTypeValid } from '../../../helpers/custom-validators/video-playlists' import { VideoPlaylistModel } from '../../../models/video/video-playlist' import { cleanUpReqFiles } from '../../../helpers/express-utils' @@ -20,6 +22,7 @@ import { VideoPlaylistElementModel } from '../../../models/video/video-playlist- import { VideoModel } from '../../../models/video/video' import { authenticatePromiseIfNeeded } from '../../oauth' import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' +import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model' const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([ async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -56,6 +59,12 @@ const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([ .json({ error: 'Cannot set "private" a video playlist that was not private.' }) } + if (videoPlaylist.type === VideoPlaylistType.WATCH_LATER) { + cleanUpReqFiles(req) + return res.status(409) + .json({ error: 'Cannot update a watch later playlist.' }) + } + if (req.body.videoChannelId && !await isVideoChannelIdExist(req.body.videoChannelId, res)) return cleanUpReqFiles(req) return next() @@ -72,6 +81,13 @@ const videoPlaylistsDeleteValidator = [ if (areValidationErrors(req, res)) return if (!await isVideoPlaylistExist(req.params.playlistId, res)) return + + const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist + if (videoPlaylist.type === VideoPlaylistType.WATCH_LATER) { + return res.status(409) + .json({ error: 'Cannot delete a watch later playlist.' }) + } + if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, res.locals.videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) { return } @@ -127,10 +143,10 @@ const videoPlaylistsAddVideoValidator = [ .custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'), body('startTimestamp') .optional() - .isInt({ min: 0 }).withMessage('Should have a valid start timestamp'), + .custom(isVideoPlaylistTimestampValid).withMessage('Should have a valid start timestamp'), body('stopTimestamp') .optional() - .isInt({ min: 0 }).withMessage('Should have a valid stop timestamp'), + .custom(isVideoPlaylistTimestampValid).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 }) @@ -167,10 +183,10 @@ const videoPlaylistsUpdateOrRemoveVideoValidator = [ .custom(isIdOrUUIDValid).withMessage('Should have an video id/uuid'), body('startTimestamp') .optional() - .isInt({ min: 0 }).withMessage('Should have a valid start timestamp'), + .custom(isVideoPlaylistTimestampValid).withMessage('Should have a valid start timestamp'), body('stopTimestamp') .optional() - .isInt({ min: 0 }).withMessage('Should have a valid stop timestamp'), + .custom(isVideoPlaylistTimestampValid).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 }) @@ -275,6 +291,20 @@ const videoPlaylistsReorderVideosValidator = [ } ] +const commonVideoPlaylistFiltersValidator = [ + query('playlistType') + .optional() + .custom(isVideoPlaylistTypeValid).withMessage('Should have a valid playlist type'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking commonVideoPlaylistFiltersValidator parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + // --------------------------------------------------------------------------- export { @@ -287,7 +317,9 @@ export { videoPlaylistsUpdateOrRemoveVideoValidator, videoPlaylistsReorderVideosValidator, - videoPlaylistElementAPGetValidator + videoPlaylistElementAPGetValidator, + + commonVideoPlaylistFiltersValidator } // --------------------------------------------------------------------------- diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index c077fb518..ca06048d1 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts @@ -67,9 +67,9 @@ type AvailableForListOptions = { ] }) @Scopes({ - [ScopeNames.SUMMARY]: (required: boolean, withAccount: boolean) => { + [ScopeNames.SUMMARY]: (withAccount = false) => { const base: IFindOptions = { - attributes: [ 'name', 'description', 'id' ], + attributes: [ 'name', 'description', 'id', 'actorId' ], include: [ { attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], @@ -225,7 +225,7 @@ export class VideoChannelModel extends Model { foreignKey: { allowNull: true }, - onDelete: 'cascade', + onDelete: 'CASCADE', hooks: true }) VideoPlaylists: VideoPlaylistModel[] diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts index 5530e0492..a2bd225a1 100644 --- a/server/models/video/video-playlist-element.ts +++ b/server/models/video/video-playlist-element.ts @@ -20,6 +20,7 @@ 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' +import * as validator from 'validator' @Table({ tableName: 'videoPlaylistElement', @@ -34,10 +35,6 @@ import { PlaylistElementObject } from '../../../shared/models/activitypub/object fields: [ 'videoPlaylistId', 'videoId' ], unique: true }, - { - fields: [ 'videoPlaylistId', 'position' ], - unique: true - }, { fields: [ 'url' ], unique: true @@ -143,7 +140,7 @@ export class VideoPlaylistElementModel extends Model return VideoPlaylistElementModel.findOne(query) } - static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number) { + static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number, t?: Sequelize.Transaction) { const query = { attributes: [ 'url' ], offset: start, @@ -151,7 +148,8 @@ export class VideoPlaylistElementModel extends Model order: getSort('position'), where: { videoPlaylistId - } + }, + transaction: t } return VideoPlaylistElementModel diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index 397887ebf..ce49f77ec 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts @@ -24,7 +24,14 @@ import { 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 { + CONFIG, + CONSTRAINTS_FIELDS, + STATIC_PATHS, + THUMBNAILS_SIZE, + VIDEO_PLAYLIST_PRIVACIES, + VIDEO_PLAYLIST_TYPES +} 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' @@ -34,22 +41,25 @@ import { PlaylistObject } from '../../../shared/models/activitypub/objects/playl import { activityPubCollectionPagination } from '../../helpers/activitypub' import { remove } from 'fs-extra' import { logger } from '../../helpers/logger' +import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model' enum ScopeNames { AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH', - WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL' + WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY', + WITH_ACCOUNT = 'WITH_ACCOUNT' } type AvailableForListOptions = { followerActorId: number - accountId?: number, + type?: VideoPlaylistType + accountId?: number videoChannelId?: number privateAndUnlisted?: boolean } @Scopes({ - [ScopeNames.WITH_VIDEOS_LENGTH]: { + [ ScopeNames.WITH_VIDEOS_LENGTH ]: { attributes: { include: [ [ @@ -59,7 +69,15 @@ type AvailableForListOptions = { ] } }, - [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: { + [ ScopeNames.WITH_ACCOUNT ]: { + include: [ + { + model: () => AccountModel, + required: true + } + ] + }, + [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: { include: [ { model: () => AccountModel.scope(AccountScopeNames.SUMMARY), @@ -71,7 +89,7 @@ type AvailableForListOptions = { } ] }, - [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { + [ 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 = { @@ -107,6 +125,12 @@ type AvailableForListOptions = { }) } + if (options.type) { + whereAnd.push({ + type: options.type + }) + } + const where = { [Sequelize.Op.and]: whereAnd } @@ -179,6 +203,11 @@ export class VideoPlaylistModel extends Model { @Column(DataType.UUID) uuid: string + @AllowNull(false) + @Default(VideoPlaylistType.REGULAR) + @Column + type: VideoPlaylistType + @ForeignKey(() => AccountModel) @Column ownerAccountId: number @@ -208,13 +237,10 @@ export class VideoPlaylistModel extends Model { name: 'videoPlaylistId', allowNull: false }, - onDelete: 'cascade' + onDelete: 'CASCADE' }) VideoPlaylistElements: VideoPlaylistElementModel[] - // Calculated field - videosLength?: number - @BeforeDestroy static async removeFiles (instance: VideoPlaylistModel) { logger.info('Removing files of video playlist %s.', instance.url) @@ -227,6 +253,7 @@ export class VideoPlaylistModel extends Model { start: number, count: number, sort: string, + type?: VideoPlaylistType, accountId?: number, videoChannelId?: number, privateAndUnlisted?: boolean @@ -242,6 +269,7 @@ export class VideoPlaylistModel extends Model { method: [ ScopeNames.AVAILABLE_FOR_LIST, { + type: options.type, followerActorId: options.followerActorId, accountId: options.accountId, videoChannelId: options.videoChannelId, @@ -289,7 +317,7 @@ export class VideoPlaylistModel extends Model { .then(e => !!e) } - static load (id: number | string, transaction: Sequelize.Transaction) { + static loadWithAccountAndChannel (id: number | string, transaction: Sequelize.Transaction) { const where = buildWhereIdOrUUID(id) const query = { @@ -298,14 +326,39 @@ export class VideoPlaylistModel extends Model { } return VideoPlaylistModel - .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ]) + .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH ]) .findOne(query) } + static loadByUrlAndPopulateAccount (url: string) { + const query = { + where: { + url + } + } + + return VideoPlaylistModel.scope(ScopeNames.WITH_ACCOUNT).findOne(query) + } + static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' } + static getTypeLabel (type: VideoPlaylistType) { + return VIDEO_PLAYLIST_TYPES[type] || 'Unknown' + } + + static resetPlaylistsOfChannel (videoChannelId: number, transaction: Sequelize.Transaction) { + const query = { + where: { + videoChannelId + }, + transaction + } + + return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query) + } + getThumbnailName () { const extension = '.jpg' @@ -345,7 +398,12 @@ export class VideoPlaylistModel extends Model { thumbnailPath: this.getThumbnailStaticPath(), - videosLength: this.videosLength, + type: { + id: this.type, + label: VideoPlaylistModel.getTypeLabel(this.type) + }, + + videosLength: this.get('videosLength'), createdAt: this.createdAt, updatedAt: this.updatedAt, @@ -355,18 +413,20 @@ export class VideoPlaylistModel extends Model { } } - toActivityPubObject (): Promise { + toActivityPubObject (page: number, t: Sequelize.Transaction): Promise { const handler = (start: number, count: number) => { - return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count) + return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t) } - return activityPubCollectionPagination(this.url, handler, null) + return activityPubCollectionPagination(this.url, handler, page) .then(o => { return Object.assign(o, { type: 'Playlist' as 'Playlist', name: this.name, content: this.description, uuid: this.uuid, + published: this.createdAt.toISOString(), + updated: this.updatedAt.toISOString(), attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [], icon: { type: 'Image' as 'Image', diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts index c87f71277..7df0ed18d 100644 --- a/server/models/video/video-share.ts +++ b/server/models/video/video-share.ts @@ -125,7 +125,7 @@ export class VideoShareModel extends Model { .then(res => res.map(r => r.Actor)) } - static loadActorsByVideoOwner (actorOwnerId: number, t: Sequelize.Transaction): Bluebird { + static loadActorsWhoSharedVideosOf (actorOwnerId: number, t: Sequelize.Transaction): Bluebird { const query = { attributes: [], include: [ diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 7a102b058..a563f78ef 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -225,7 +225,7 @@ type AvailableForListIDsOptions = { }, include: [ { - model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY) + model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, true ] }) } ] } @@ -1535,18 +1535,7 @@ export class VideoModel extends Model { if (ids.length === 0) return { data: [], total: count } - // FIXME: typings - const apiScope: any[] = [ - { - method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ] - } - ] - - if (options.user) { - apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] }) - } - - const secondQuery = { + const secondQuery: IFindOptions = { offset: 0, limit: query.limit, attributes: query.attributes, @@ -1556,6 +1545,29 @@ export class VideoModel extends Model { ) ] } + + // FIXME: typing + const apiScope: any[] = [] + + if (options.user) { + apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] }) + + // Even if the relation is n:m, we know that a user only have 0..1 video history + // So we won't have multiple rows for the same video + // A subquery adds some bugs in our query so disable it + secondQuery.subQuery = false + } + + apiScope.push({ + method: [ + ScopeNames.FOR_API, { + ids, withFiles: + options.withFiles, + videoPlaylistId: options.videoPlaylistId + } as ForAPIOptions + ] + }) + const rows = await VideoModel.scope(apiScope).findAll(secondQuery) return { diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 77c17036a..ca51cd39a 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -16,6 +16,7 @@ import './video-captions' import './video-channels' import './video-comments' import './video-imports' +import './video-playlists' import './videos' import './videos-filter' import './videos-history' diff --git a/server/tests/api/check-params/video-playlists.ts b/server/tests/api/check-params/video-playlists.ts index 68fe362e9..803e7afb9 100644 --- a/server/tests/api/check-params/video-playlists.ts +++ b/server/tests/api/check-params/video-playlists.ts @@ -2,20 +2,24 @@ import 'mocha' import { - createUser, + addVideoInPlaylist, createVideoPlaylist, deleteVideoPlaylist, flushTests, + generateUserAccessToken, + getAccountPlaylistsListWithToken, getVideoPlaylist, immutableAssign, killallServers, makeGetRequest, + removeVideoFromPlaylist, + reorderVideosPlaylist, runServer, ServerInfo, setAccessTokensToServers, updateVideoPlaylist, - userLogin, - addVideoInPlaylist, uploadVideo, updateVideoPlaylistElement, removeVideoFromPlaylist, reorderVideosPlaylist + updateVideoPlaylistElement, + uploadVideoAndGetId } from '../../../../shared/utils' import { checkBadCountPagination, @@ -23,11 +27,13 @@ import { checkBadStartPagination } from '../../../../shared/utils/requests/check-api-params' import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' +import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model' describe('Test video playlists API validator', function () { let server: ServerInfo - let userAccessToken = '' + let userAccessToken: string let playlistUUID: string + let watchLaterPlaylistId: number let videoId: number let videoId2: number @@ -42,19 +48,13 @@ describe('Test video playlists API validator', function () { 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 }) + userAccessToken = await generateUserAccessToken(server, 'user1') + videoId = (await uploadVideoAndGetId({ server, videoName: 'video 1' })).id + videoId2 = (await uploadVideoAndGetId({ server, videoName: 'video 2' })).id { - const res = await uploadVideo(server.url, server.accessToken, { name: 'video 1' }) - videoId = res.body.video.id - } - - { - const res = await uploadVideo(server.url, server.accessToken, { name: 'video 2' }) - videoId2 = res.body.video.id + const res = await getAccountPlaylistsListWithToken(server.url, server.accessToken, 'root',0, 5, VideoPlaylistType.WATCH_LATER) + watchLaterPlaylistId = res.body.data[0].id } { @@ -93,6 +93,12 @@ describe('Test video playlists API validator', function () { await checkBadSortPagination(server.url, videoChannelPath, server.accessToken) }) + it('Should fail with a bad playlist type', async function () { + await makeGetRequest({ url: server.url, path: globalPath, query: { playlistType: 3 } }) + await makeGetRequest({ url: server.url, path: accountPath, query: { playlistType: 3 } }) + await makeGetRequest({ url: server.url, path: videoChannelPath, query: { playlistType: 3 } }) + }) + it('Should fail with a bad account parameter', async function () { const accountPath = '/api/v1/accounts/root2/video-playlists' @@ -158,410 +164,250 @@ describe('Test video playlists API validator', function () { }) describe('When creating/updating a video playlist', function () { + const getBase = (playlistAttrs: any = {}, wrapper: any = {}) => { + return Object.assign({ + expectedStatus: 400, + url: server.url, + token: server.accessToken, + playlistAttrs: Object.assign({ + displayName: 'display name', + privacy: VideoPlaylistPrivacy.UNLISTED, + thumbnailfile: 'thumbnail.jpg' + }, playlistAttrs) + }, wrapper) + } + const getUpdate = (params: any, playlistId: number | string) => { + return immutableAssign(params, { playlistId: playlistId }) + } it('Should fail with an unauthenticated user', async function () { - const baseParams = { - url: server.url, - token: null, - playlistAttrs: { - displayName: 'super playlist', - privacy: VideoPlaylistPrivacy.PUBLIC - }, - expectedStatus: 401 - } + const params = getBase({}, { token: null, expectedStatus: 401 }) - await createVideoPlaylist(baseParams) - await updateVideoPlaylist(immutableAssign(baseParams, { playlistId: playlistUUID })) + await createVideoPlaylist(params) + await updateVideoPlaylist(getUpdate(params, playlistUUID)) }) it('Should fail without displayName', async function () { - const baseParams = { - url: server.url, - token: server.accessToken, - playlistAttrs: { - privacy: VideoPlaylistPrivacy.PUBLIC - } as any, - expectedStatus: 400 - } + const params = getBase({ displayName: undefined }) - await createVideoPlaylist(baseParams) - await updateVideoPlaylist(immutableAssign(baseParams, { playlistId: playlistUUID })) + await createVideoPlaylist(params) + await updateVideoPlaylist(getUpdate(params, playlistUUID)) }) it('Should fail with an incorrect display name', async function () { - const baseParams = { - url: server.url, - token: server.accessToken, - playlistAttrs: { - displayName: 's'.repeat(300), - privacy: VideoPlaylistPrivacy.PUBLIC - }, - expectedStatus: 400 - } + const params = getBase({ displayName: 's'.repeat(300) }) - await createVideoPlaylist(baseParams) - await updateVideoPlaylist(immutableAssign(baseParams, { playlistId: playlistUUID })) + await createVideoPlaylist(params) + await updateVideoPlaylist(getUpdate(params, playlistUUID)) }) it('Should fail with an incorrect description', async function () { - const baseParams = { - url: server.url, - token: server.accessToken, - playlistAttrs: { - displayName: 'display name', - privacy: VideoPlaylistPrivacy.PUBLIC, - description: 't' - }, - expectedStatus: 400 - } + const params = getBase({ description: 't' }) - await createVideoPlaylist(baseParams) - await updateVideoPlaylist(immutableAssign(baseParams, { playlistId: playlistUUID })) + await createVideoPlaylist(params) + await updateVideoPlaylist(getUpdate(params, playlistUUID)) }) it('Should fail with an incorrect privacy', async function () { - const baseParams = { - url: server.url, - token: server.accessToken, - playlistAttrs: { - displayName: 'display name', - privacy: 45 - } as any, - expectedStatus: 400 - } + const params = getBase({ privacy: 45 }) - await createVideoPlaylist(baseParams) - await updateVideoPlaylist(immutableAssign(baseParams, { playlistId: playlistUUID })) + await createVideoPlaylist(params) + await updateVideoPlaylist(getUpdate(params, playlistUUID)) }) it('Should fail with an unknown video channel id', async function () { - const baseParams = { - url: server.url, - token: server.accessToken, - playlistAttrs: { - displayName: 'display name', - privacy: VideoPlaylistPrivacy.PUBLIC, - videoChannelId: 42 - }, - expectedStatus: 404 - } + const params = getBase({ videoChannelId: 42 }, { expectedStatus: 404 }) - await createVideoPlaylist(baseParams) - await updateVideoPlaylist(immutableAssign(baseParams, { playlistId: playlistUUID })) + await createVideoPlaylist(params) + await updateVideoPlaylist(getUpdate(params, playlistUUID)) }) it('Should fail with an incorrect thumbnail file', async function () { - const baseParams = { - url: server.url, - token: server.accessToken, - playlistAttrs: { - displayName: 'display name', - privacy: VideoPlaylistPrivacy.PUBLIC, - thumbnailfile: 'avatar.png' - }, - expectedStatus: 400 - } + const params = getBase({ thumbnailfile: 'avatar.png' }) - await createVideoPlaylist(baseParams) - await updateVideoPlaylist(immutableAssign(baseParams, { playlistId: playlistUUID })) + await createVideoPlaylist(params) + await updateVideoPlaylist(getUpdate(params, playlistUUID)) }) it('Should fail with an unknown playlist to update', async function () { - await updateVideoPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: 42, - playlistAttrs: { - displayName: 'display name', - privacy: VideoPlaylistPrivacy.PUBLIC - }, - expectedStatus: 404 - }) + await updateVideoPlaylist(getUpdate( + getBase({}, { expectedStatus: 404 }), + 42 + )) }) it('Should fail to update a playlist of another user', async function () { - await updateVideoPlaylist({ - url: server.url, - token: userAccessToken, - playlistId: playlistUUID, - playlistAttrs: { - displayName: 'display name', - privacy: VideoPlaylistPrivacy.PUBLIC - }, - expectedStatus: 403 - }) + await updateVideoPlaylist(getUpdate( + getBase({}, { token: userAccessToken, expectedStatus: 403 }), + playlistUUID + )) }) it('Should fail to update to private a public/unlisted playlist', async function () { - const res = await createVideoPlaylist({ - url: server.url, - token: server.accessToken, - playlistAttrs: { - displayName: 'super playlist', - privacy: VideoPlaylistPrivacy.PUBLIC - } - }) + const params = getBase({ privacy: VideoPlaylistPrivacy.PUBLIC }, { expectedStatus: 200 }) + + const res = await createVideoPlaylist(params) const playlist = res.body.videoPlaylist - await updateVideoPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlist.id, - playlistAttrs: { - displayName: 'display name', - privacy: VideoPlaylistPrivacy.PRIVATE - }, - expectedStatus: 409 - }) + const paramsUpdate = getBase({ privacy: VideoPlaylistPrivacy.PRIVATE }, { expectedStatus: 409 }) + + await updateVideoPlaylist(getUpdate(paramsUpdate, playlist.id)) + }) + + it('Should fail to update the watch later playlist', async function () { + await updateVideoPlaylist(getUpdate( + getBase({}, { expectedStatus: 409 }), + watchLaterPlaylistId + )) }) it('Should succeed with the correct params', async function () { - const baseParams = { - url: server.url, - token: server.accessToken, - playlistAttrs: { - displayName: 'display name', - privacy: VideoPlaylistPrivacy.UNLISTED, - thumbnailfile: 'thumbnail.jpg' - } + { + const params = getBase({}, { expectedStatus: 200 }) + await createVideoPlaylist(params) } - await createVideoPlaylist(baseParams) - await updateVideoPlaylist(immutableAssign(baseParams, { playlistId: playlistUUID })) + { + const params = getBase({}, { expectedStatus: 204 }) + await updateVideoPlaylist(getUpdate(params, playlistUUID)) + } }) }) describe('When adding an element in a playlist', function () { - it('Should fail with an unauthenticated user', async function () { - await addVideoInPlaylist({ + const getBase = (elementAttrs: any = {}, wrapper: any = {}) => { + return Object.assign({ + expectedStatus: 400, url: server.url, - token: null, - elementAttrs: { - videoId: videoId - }, + token: server.accessToken, playlistId: playlistUUID, - expectedStatus: 401 - }) + elementAttrs: Object.assign({ + videoId: videoId, + startTimestamp: 2, + stopTimestamp: 3 + }, elementAttrs) + }, wrapper) + } + + it('Should fail with an unauthenticated user', async function () { + const params = getBase({}, { token: null, expectedStatus: 401 }) + await addVideoInPlaylist(params) }) it('Should fail with the playlist of another user', async function () { - await addVideoInPlaylist({ - url: server.url, - token: userAccessToken, - elementAttrs: { - videoId: videoId - }, - playlistId: playlistUUID, - expectedStatus: 403 - }) + const params = getBase({}, { token: userAccessToken, expectedStatus: 403 }) + await addVideoInPlaylist(params) }) it('Should fail with an unknown or incorrect playlist id', async function () { - await addVideoInPlaylist({ - url: server.url, - token: server.accessToken, - elementAttrs: { - videoId: videoId - }, - playlistId: 'toto', - expectedStatus: 400 - }) + { + const params = getBase({}, { playlistId: 'toto' }) + await addVideoInPlaylist(params) + } - await addVideoInPlaylist({ - url: server.url, - token: server.accessToken, - elementAttrs: { - videoId: videoId - }, - playlistId: 42, - expectedStatus: 404 - }) + { + const params = getBase({}, { playlistId: 42, expectedStatus: 404 }) + await addVideoInPlaylist(params) + } }) it('Should fail with an unknown or incorrect video id', async function () { - await addVideoInPlaylist({ - url: server.url, - token: server.accessToken, - elementAttrs: { - videoId: 'toto' as any - }, - playlistId: playlistUUID, - expectedStatus: 400 - }) - - await addVideoInPlaylist({ - url: server.url, - token: server.accessToken, - elementAttrs: { - videoId: 42 - }, - playlistId: playlistUUID, - expectedStatus: 404 - }) + const params = getBase({ videoId: 42 }, { expectedStatus: 404 }) + await addVideoInPlaylist(params) }) it('Should fail with a bad start/stop timestamp', async function () { - await addVideoInPlaylist({ - url: server.url, - token: server.accessToken, - elementAttrs: { - videoId: videoId, - startTimestamp: -42 - }, - playlistId: playlistUUID, - expectedStatus: 400 - }) + { + const params = getBase({ startTimestamp: -42 }) + await addVideoInPlaylist(params) + } - await addVideoInPlaylist({ - url: server.url, - token: server.accessToken, - elementAttrs: { - videoId: videoId, - stopTimestamp: 'toto' as any - }, - playlistId: playlistUUID, - expectedStatus: 400 - }) + { + const params = getBase({ stopTimestamp: 'toto' as any }) + await addVideoInPlaylist(params) + } }) it('Succeed with the correct params', async function () { - await addVideoInPlaylist({ - url: server.url, - token: server.accessToken, - elementAttrs: { - videoId: videoId, - stopTimestamp: 3 - }, - playlistId: playlistUUID, - expectedStatus: 200 - }) + const params = getBase({}, { expectedStatus: 200 }) + await addVideoInPlaylist(params) }) it('Should fail if the video was already added in the playlist', async function () { - await addVideoInPlaylist({ - url: server.url, - token: server.accessToken, - elementAttrs: { - videoId: videoId, - stopTimestamp: 3 - }, - playlistId: playlistUUID, - expectedStatus: 409 - }) + const params = getBase({}, { expectedStatus: 409 }) + await addVideoInPlaylist(params) }) }) describe('When updating an element in a playlist', function () { - it('Should fail with an unauthenticated user', async function () { - await updateVideoPlaylistElement({ + const getBase = (elementAttrs: any = {}, wrapper: any = {}) => { + return Object.assign({ url: server.url, - token: null, - elementAttrs: { }, + token: server.accessToken, + elementAttrs: Object.assign({ + startTimestamp: 1, + stopTimestamp: 2 + }, elementAttrs), videoId: videoId, playlistId: playlistUUID, - expectedStatus: 401 - }) + expectedStatus: 400 + }, wrapper) + } + + it('Should fail with an unauthenticated user', async function () { + const params = getBase({}, { token: null, expectedStatus: 401 }) + await updateVideoPlaylistElement(params) }) it('Should fail with the playlist of another user', async function () { - await updateVideoPlaylistElement({ - url: server.url, - token: userAccessToken, - elementAttrs: { }, - videoId: videoId, - playlistId: playlistUUID, - expectedStatus: 403 - }) + const params = getBase({}, { token: userAccessToken, expectedStatus: 403 }) + await updateVideoPlaylistElement(params) }) it('Should fail with an unknown or incorrect playlist id', async function () { - await updateVideoPlaylistElement({ - url: server.url, - token: server.accessToken, - elementAttrs: { }, - videoId: videoId, - playlistId: 'toto', - expectedStatus: 400 - }) + { + const params = getBase({}, { playlistId: 'toto' }) + await updateVideoPlaylistElement(params) + } - await updateVideoPlaylistElement({ - url: server.url, - token: server.accessToken, - elementAttrs: { }, - videoId: videoId, - playlistId: 42, - expectedStatus: 404 - }) + { + const params = getBase({}, { playlistId: 42, expectedStatus: 404 }) + await updateVideoPlaylistElement(params) + } }) it('Should fail with an unknown or incorrect video id', async function () { - await updateVideoPlaylistElement({ - url: server.url, - token: server.accessToken, - elementAttrs: { }, - videoId: 'toto', - playlistId: playlistUUID, - expectedStatus: 400 - }) + { + const params = getBase({}, { videoId: 'toto' }) + await updateVideoPlaylistElement(params) + } - await updateVideoPlaylistElement({ - url: server.url, - token: server.accessToken, - elementAttrs: { }, - videoId: 42, - playlistId: playlistUUID, - expectedStatus: 404 - }) + { + const params = getBase({}, { videoId: 42, expectedStatus: 404 }) + await updateVideoPlaylistElement(params) + } }) it('Should fail with a bad start/stop timestamp', async function () { - await updateVideoPlaylistElement({ - url: server.url, - token: server.accessToken, - elementAttrs: { - startTimestamp: 'toto' as any - }, - videoId: videoId, - playlistId: playlistUUID, - expectedStatus: 400 - }) + { + const params = getBase({ startTimestamp: 'toto' as any }) + await updateVideoPlaylistElement(params) + } - await updateVideoPlaylistElement({ - url: server.url, - token: server.accessToken, - elementAttrs: { - stopTimestamp: -42 - }, - videoId: videoId, - playlistId: playlistUUID, - expectedStatus: 400 - }) + { + const params = getBase({ stopTimestamp: -42 }) + await updateVideoPlaylistElement(params) + } }) it('Should fail with an unknown element', async function () { - await updateVideoPlaylistElement({ - url: server.url, - token: server.accessToken, - elementAttrs: { - stopTimestamp: 2 - }, - videoId: videoId2, - playlistId: playlistUUID, - expectedStatus: 404 - }) + const params = getBase({}, { videoId: videoId2, expectedStatus: 404 }) + await updateVideoPlaylistElement(params) }) it('Succeed with the correct params', async function () { - await updateVideoPlaylistElement({ - url: server.url, - token: server.accessToken, - elementAttrs: { - stopTimestamp: 2 - }, - videoId: videoId, - playlistId: playlistUUID, - expectedStatus: 204 - }) + const params = getBase({}, { expectedStatus: 204 }) + await updateVideoPlaylistElement(params) }) }) @@ -569,280 +415,166 @@ describe('Test video playlists API validator', function () { let videoId3: number let videoId4: number - before(async function () { - { - const res = await uploadVideo(server.url, server.accessToken, { name: 'video 3' }) - videoId3 = res.body.video.id - } - - { - const res = await uploadVideo(server.url, server.accessToken, { name: 'video 4' }) - videoId4 = res.body.video.id - } - - await addVideoInPlaylist({ + const getBase = (elementAttrs: any = {}, wrapper: any = {}) => { + return Object.assign({ url: server.url, token: server.accessToken, playlistId: playlistUUID, - elementAttrs: { videoId: videoId3 } - }) + elementAttrs: Object.assign({ + startPosition: 1, + insertAfterPosition: 2, + reorderLength: 3 + }, elementAttrs), + expectedStatus: 400 + }, wrapper) + } - await addVideoInPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { videoId: videoId4 } - }) + before(async function () { + videoId3 = (await uploadVideoAndGetId({ server, videoName: 'video 3' })).id + videoId4 = (await uploadVideoAndGetId({ server, videoName: 'video 4' })).id + + for (let id of [ videoId3, videoId4 ]) { + await addVideoInPlaylist({ + url: server.url, + token: server.accessToken, + playlistId: playlistUUID, + elementAttrs: { videoId: id } + }) + } }) it('Should fail with an unauthenticated user', async function () { - await reorderVideosPlaylist({ - url: server.url, - token: null, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 1, - insertAfterPosition: 2 - }, - expectedStatus: 401 - }) + const params = getBase({}, { token: null, expectedStatus: 401 }) + await reorderVideosPlaylist(params) }) it('Should fail with the playlist of another user', async function () { - await reorderVideosPlaylist({ - url: server.url, - token: userAccessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 1, - insertAfterPosition: 2 - }, - expectedStatus: 403 - }) + const params = getBase({}, { token: userAccessToken, expectedStatus: 403 }) + await reorderVideosPlaylist(params) }) it('Should fail with an invalid playlist', async function () { - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: 'toto', - elementAttrs: { - startPosition: 1, - insertAfterPosition: 2 - }, - expectedStatus: 400 - }) + { + const params = getBase({}, { playlistId: 'toto' }) + await reorderVideosPlaylist(params) + } - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: 42, - elementAttrs: { - startPosition: 1, - insertAfterPosition: 2 - }, - expectedStatus: 404 - }) + { + const params = getBase({}, { playlistId: 42, expectedStatus: 404 }) + await reorderVideosPlaylist(params) + } }) it('Should fail with an invalid start position', async function () { - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: -1, - insertAfterPosition: 2 - }, - expectedStatus: 400 - }) + { + const params = getBase({ startPosition: -1 }) + await reorderVideosPlaylist(params) + } - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 'toto' as any, - insertAfterPosition: 2 - }, - expectedStatus: 400 - }) + { + const params = getBase({ startPosition: 'toto' as any }) + await reorderVideosPlaylist(params) + } - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 42, - insertAfterPosition: 2 - }, - expectedStatus: 400 - }) + { + const params = getBase({ startPosition: 42 }) + await reorderVideosPlaylist(params) + } }) it('Should fail with an invalid insert after position', async function () { - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 1, - insertAfterPosition: 'toto' as any - }, - expectedStatus: 400 - }) + { + const params = getBase({ insertAfterPosition: 'toto' as any }) + await reorderVideosPlaylist(params) + } - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 1, - insertAfterPosition: -2 - }, - expectedStatus: 400 - }) + { + const params = getBase({ insertAfterPosition: -2 }) + await reorderVideosPlaylist(params) + } - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 1, - insertAfterPosition: 42 - }, - expectedStatus: 400 - }) + { + const params = getBase({ insertAfterPosition: 42 }) + await reorderVideosPlaylist(params) + } }) it('Should fail with an invalid reorder length', async function () { - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 1, - insertAfterPosition: 2, - reorderLength: 'toto' as any - }, - expectedStatus: 400 - }) + { + const params = getBase({ reorderLength: 'toto' as any }) + await reorderVideosPlaylist(params) + } - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 1, - insertAfterPosition: 2, - reorderLength: -1 - }, - expectedStatus: 400 - }) + { + const params = getBase({ reorderLength: -2 }) + await reorderVideosPlaylist(params) + } - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 1, - insertAfterPosition: 2, - reorderLength: 4 - }, - expectedStatus: 400 - }) + { + const params = getBase({ reorderLength: 42 }) + await reorderVideosPlaylist(params) + } }) it('Succeed with the correct params', async function () { - await reorderVideosPlaylist({ - url: server.url, - token: server.accessToken, - playlistId: playlistUUID, - elementAttrs: { - startPosition: 1, - insertAfterPosition: 2, - reorderLength: 3 - }, - expectedStatus: 204 - }) + const params = getBase({}, { expectedStatus: 204 }) + await reorderVideosPlaylist(params) }) }) describe('When deleting an element in a playlist', function () { - it('Should fail with an unauthenticated user', async function () { - await removeVideoFromPlaylist({ + const getBase = (wrapper: any = {}) => { + return Object.assign({ url: server.url, - token: null, - videoId, + token: server.accessToken, + videoId: videoId, playlistId: playlistUUID, - expectedStatus: 401 - }) + expectedStatus: 400 + }, wrapper) + } + + it('Should fail with an unauthenticated user', async function () { + const params = getBase({ token: null, expectedStatus: 401 }) + await removeVideoFromPlaylist(params) }) it('Should fail with the playlist of another user', async function () { - await removeVideoFromPlaylist({ - url: server.url, - token: userAccessToken, - videoId, - playlistId: playlistUUID, - expectedStatus: 403 - }) + const params = getBase({ token: userAccessToken, expectedStatus: 403 }) + await removeVideoFromPlaylist(params) }) it('Should fail with an unknown or incorrect playlist id', async function () { - await removeVideoFromPlaylist({ - url: server.url, - token: server.accessToken, - videoId, - playlistId: 'toto', - expectedStatus: 400 - }) + { + const params = getBase({ playlistId: 'toto' }) + await removeVideoFromPlaylist(params) + } - await removeVideoFromPlaylist({ - url: server.url, - token: server.accessToken, - videoId, - playlistId: 42, - expectedStatus: 404 - }) + { + const params = getBase({ playlistId: 42, expectedStatus: 404 }) + await removeVideoFromPlaylist(params) + } }) it('Should fail with an unknown or incorrect video id', async function () { - await removeVideoFromPlaylist({ - url: server.url, - token: server.accessToken, - videoId: 'toto', - playlistId: playlistUUID, - expectedStatus: 400 - }) + { + const params = getBase({ videoId: 'toto' }) + await removeVideoFromPlaylist(params) + } - await removeVideoFromPlaylist({ - url: server.url, - token: server.accessToken, - videoId: 42, - playlistId: playlistUUID, - expectedStatus: 404 - }) + { + const params = getBase({ videoId: 42, expectedStatus: 404 }) + await removeVideoFromPlaylist(params) + } }) it('Should fail with an unknown element', async function () { - await removeVideoFromPlaylist({ - url: server.url, - token: server.accessToken, - videoId: videoId2, - playlistId: playlistUUID, - expectedStatus: 404 - }) + const params = getBase({ videoId: videoId2, expectedStatus: 404 }) + await removeVideoFromPlaylist(params) }) it('Succeed with the correct params', async function () { - await removeVideoFromPlaylist({ - url: server.url, - token: server.accessToken, - videoId: videoId, - playlistId: playlistUUID, - expectedStatus: 204 - }) + const params = getBase({ expectedStatus: 204 }) + await removeVideoFromPlaylist(params) }) }) @@ -855,6 +587,10 @@ describe('Test video playlists API validator', function () { await deleteVideoPlaylist(server.url, userAccessToken, playlistUUID, 403) }) + it('Should fail with the watch later playlist', async function () { + await deleteVideoPlaylist(server.url, server.accessToken, watchLaterPlaylistId, 409) + }) + it('Should succeed with the correct params', async function () { await deleteVideoPlaylist(server.url, server.accessToken, playlistUUID) }) diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts index cb23239da..7dd1563fc 100644 --- a/server/tests/api/videos/video-playlists.ts +++ b/server/tests/api/videos/video-playlists.ts @@ -2,152 +2,768 @@ 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, + addVideoInPlaylist, + checkPlaylistFilesWereRemoved, createUser, - dateIsValid, + createVideoPlaylist, + deleteVideoChannel, + deleteVideoPlaylist, doubleFollow, flushAndRunMultipleServers, flushTests, - getLocalVideos, - getVideo, - getVideoChannelsList, - getVideosList, + getAccountPlaylistsList, + getAccountPlaylistsListWithToken, + getPlaylistVideos, + getVideoChannelPlaylistsList, + getVideoPlaylist, + getVideoPlaylistsList, + getVideoPlaylistWithToken, killallServers, - rateVideo, - removeVideo, + removeUser, + removeVideoFromPlaylist, + reorderVideosPlaylist, ServerInfo, setAccessTokensToServers, + setDefaultVideoChannel, testImage, - updateVideo, + unfollow, + updateVideoPlaylist, + updateVideoPlaylistElement, uploadVideo, + uploadVideoAndGetId, userLogin, - viewVideo, - wait, - webtorrentAdd + waitJobs } from '../../../../shared/utils' -import { - addVideoCommentReply, - addVideoCommentThread, - deleteVideoComment, - getVideoCommentThreads, - getVideoThreadComments -} from '../../../../shared/utils/videos/video-comments' -import { waitJobs } from '../../../../shared/utils/server/jobs' +import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' +import { VideoPlaylist } from '../../../../shared/models/videos/playlist/video-playlist.model' +import { Video } from '../../../../shared/models/videos' +import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model' const expect = chai.expect describe('Test video playlists', function () { let servers: ServerInfo[] = [] + let playlistServer2Id1: number + let playlistServer2Id2: number + let playlistServer2UUID2: number + + let playlistServer1Id: number + let playlistServer1UUID: string + + let nsfwVideoServer1: number + before(async function () { this.timeout(120000) - servers = await flushAndRunMultipleServers(3) + servers = await flushAndRunMultipleServers(3, { transcoding: { enabled: false } }) // Get the access tokens await setAccessTokensToServers(servers) + await setDefaultVideoChannel(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]) + + { + const serverPromises: Promise[][] = [] + + for (const server of servers) { + const videoPromises: Promise[] = [] + + for (let i = 0; i < 7; i++) { + videoPromises.push( + uploadVideo(server.url, server.accessToken, { name: `video ${i} server ${server.serverNumber}`, nsfw: false }) + .then(res => res.body.video) + ) + } + + serverPromises.push(videoPromises) + } + + servers[0].videos = await Promise.all(serverPromises[0]) + servers[1].videos = await Promise.all(serverPromises[1]) + servers[2].videos = await Promise.all(serverPromises[2]) + } + + nsfwVideoServer1 = (await uploadVideoAndGetId({ server: servers[ 0 ], videoName: 'NSFW video', nsfw: true })).id + + await waitJobs(servers) }) - it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () { + it('Should list watch later playlist', async function () { + const url = servers[ 0 ].url + const accessToken = servers[ 0 ].accessToken + + { + const res = await getAccountPlaylistsListWithToken(url, accessToken, 'root', 0, 5, VideoPlaylistType.WATCH_LATER) + + expect(res.body.total).to.equal(1) + expect(res.body.data).to.have.lengthOf(1) + const playlist: VideoPlaylist = res.body.data[ 0 ] + expect(playlist.displayName).to.equal('Watch later') + expect(playlist.type.id).to.equal(VideoPlaylistType.WATCH_LATER) + expect(playlist.type.label).to.equal('Watch later') + } + + { + const res = await getAccountPlaylistsListWithToken(url, accessToken, 'root', 0, 5, VideoPlaylistType.REGULAR) + + expect(res.body.total).to.equal(0) + expect(res.body.data).to.have.lengthOf(0) + } + + { + const res = await getAccountPlaylistsList(url, 'root', 0, 5) + expect(res.body.total).to.equal(0) + expect(res.body.data).to.have.lengthOf(0) + } + }) + + it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () { + this.timeout(30000) + + await createVideoPlaylist({ + url: servers[0].url, + token: servers[0].accessToken, + playlistAttrs: { + displayName: 'my super playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + description: 'my super description', + thumbnailfile: 'thumbnail.jpg', + videoChannelId: servers[0].videoChannel.id + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const res = await getVideoPlaylistsList(server.url, 0, 5) + expect(res.body.total).to.equal(1) + expect(res.body.data).to.have.lengthOf(1) + + const playlistFromList = res.body.data[0] as VideoPlaylist + + const res2 = await getVideoPlaylist(server.url, playlistFromList.uuid) + const playlistFromGet = res2.body + + for (const playlist of [ playlistFromGet, playlistFromList ]) { + expect(playlist.id).to.be.a('number') + expect(playlist.uuid).to.be.a('string') + + expect(playlist.isLocal).to.equal(server.serverNumber === 1) + + expect(playlist.displayName).to.equal('my super playlist') + expect(playlist.description).to.equal('my super description') + expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC) + expect(playlist.privacy.label).to.equal('Public') + expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR) + expect(playlist.type.label).to.equal('Regular') + + expect(playlist.videosLength).to.equal(0) + + expect(playlist.ownerAccount.name).to.equal('root') + expect(playlist.ownerAccount.displayName).to.equal('root') + expect(playlist.videoChannel.name).to.equal('root_channel') + expect(playlist.videoChannel.displayName).to.equal('Main root channel') + } + } }) 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 + this.timeout(30000) + + { + const res = await createVideoPlaylist({ + url: servers[1].url, + token: servers[1].accessToken, + playlistAttrs: { + displayName: 'playlist 2', + privacy: VideoPlaylistPrivacy.PUBLIC + } + }) + playlistServer2Id1 = res.body.videoPlaylist.id + } + + { + const res = await createVideoPlaylist({ + url: servers[ 1 ].url, + token: servers[ 1 ].accessToken, + playlistAttrs: { + displayName: 'playlist 3', + privacy: VideoPlaylistPrivacy.PUBLIC, + thumbnailfile: 'thumbnail.jpg' + } + }) + + playlistServer2Id2 = res.body.videoPlaylist.id + playlistServer2UUID2 = res.body.videoPlaylist.uuid + } + + for (let id of [ playlistServer2Id1, playlistServer2Id2 ]) { + await addVideoInPlaylist({ + url: servers[ 1 ].url, + token: servers[ 1 ].accessToken, + playlistId: id, + elementAttrs: { videoId: servers[ 1 ].videos[ 0 ].id, startTimestamp: 1, stopTimestamp: 2 } + }) + await addVideoInPlaylist({ + url: servers[ 1 ].url, + token: servers[ 1 ].accessToken, + playlistId: id, + elementAttrs: { videoId: servers[ 1 ].videos[ 1 ].id } + }) + } + + await waitJobs(servers) + + for (const server of [ servers[0], servers[1] ]) { + const res = await getVideoPlaylistsList(server.url, 0, 5) + + const playlist2 = res.body.data.find(p => p.displayName === 'playlist 2') + expect(playlist2).to.not.be.undefined + await testImage(server.url, 'thumbnail-playlist', playlist2.thumbnailPath) + + const playlist3 = res.body.data.find(p => p.displayName === 'playlist 3') + expect(playlist3).to.not.be.undefined + await testImage(server.url, 'thumbnail', playlist3.thumbnailPath) + } + + const res = await getVideoPlaylistsList(servers[2].url, 0, 5) + expect(res.body.data.find(p => p.displayName === 'playlist 2')).to.be.undefined + expect(res.body.data.find(p => p.displayName === 'playlist 3')).to.be.undefined }) it('Should have the playlist on server 3 after a new follow', async function () { + this.timeout(30000) + // Server 2 and server 3 follow each other await doubleFollow(servers[1], servers[2]) + + const res = await getVideoPlaylistsList(servers[2].url, 0, 5) + + const playlist2 = res.body.data.find(p => p.displayName === 'playlist 2') + expect(playlist2).to.not.be.undefined + await testImage(servers[2].url, 'thumbnail-playlist', playlist2.thumbnailPath) + + expect(res.body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined }) - 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 correctly list the playlists', async function () { + this.timeout(30000) + + { + const res = await getVideoPlaylistsList(servers[ 2 ].url, 1, 2, 'createdAt') + + expect(res.body.total).to.equal(3) + + const data: VideoPlaylist[] = res.body.data + expect(data).to.have.lengthOf(2) + expect(data[ 0 ].displayName).to.equal('playlist 2') + expect(data[ 1 ].displayName).to.equal('playlist 3') + } + + { + const res = await getVideoPlaylistsList(servers[ 2 ].url, 1, 2, '-createdAt') + + expect(res.body.total).to.equal(3) + + const data: VideoPlaylist[] = res.body.data + expect(data).to.have.lengthOf(2) + expect(data[ 0 ].displayName).to.equal('playlist 2') + expect(data[ 1 ].displayName).to.equal('my super playlist') + } }) it('Should list video channel playlists', async function () { - // check pagination - // check sort - // check empty + this.timeout(30000) + + { + const res = await getVideoChannelPlaylistsList(servers[ 0 ].url, 'root_channel', 0, 2, '-createdAt') + + expect(res.body.total).to.equal(1) + + const data: VideoPlaylist[] = res.body.data + expect(data).to.have.lengthOf(1) + expect(data[ 0 ].displayName).to.equal('my super playlist') + } }) it('Should list account playlists', async function () { - // check pagination - // check sort - // check empty + this.timeout(30000) + + { + const res = await getAccountPlaylistsList(servers[ 1 ].url, 'root', 1, 2, '-createdAt') + + expect(res.body.total).to.equal(2) + + const data: VideoPlaylist[] = res.body.data + expect(data).to.have.lengthOf(1) + expect(data[ 0 ].displayName).to.equal('playlist 2') + } + + { + const res = await getAccountPlaylistsList(servers[ 1 ].url, 'root', 1, 2, 'createdAt') + + expect(res.body.total).to.equal(2) + + const data: VideoPlaylist[] = res.body.data + expect(data).to.have.lengthOf(1) + expect(data[ 0 ].displayName).to.equal('playlist 3') + } }) - it('Should get a playlist', async function () { - // get empty playlist - // get non empty playlist + it('Should not list unlisted or private playlists', async function () { + this.timeout(30000) + + await createVideoPlaylist({ + url: servers[ 1 ].url, + token: servers[ 1 ].accessToken, + playlistAttrs: { + displayName: 'playlist unlisted', + privacy: VideoPlaylistPrivacy.UNLISTED + } + }) + + await createVideoPlaylist({ + url: servers[ 1 ].url, + token: servers[ 1 ].accessToken, + playlistAttrs: { + displayName: 'playlist private', + privacy: VideoPlaylistPrivacy.PRIVATE + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const results = [ + await getAccountPlaylistsList(server.url, 'root@localhost:9002', 0, 5, '-createdAt'), + await getVideoPlaylistsList(server.url, 0, 2, '-createdAt') + ] + + expect(results[0].body.total).to.equal(2) + expect(results[1].body.total).to.equal(3) + + for (const res of results) { + const data: VideoPlaylist[] = res.body.data + expect(data).to.have.lengthOf(2) + expect(data[ 0 ].displayName).to.equal('playlist 3') + expect(data[ 1 ].displayName).to.equal('playlist 2') + } + } }) it('Should update a playlist', async function () { - // update thumbnail - - // update other details + this.timeout(30000) + + await updateVideoPlaylist({ + url: servers[1].url, + token: servers[1].accessToken, + playlistAttrs: { + displayName: 'playlist 3 updated', + description: 'description updated', + privacy: VideoPlaylistPrivacy.UNLISTED, + thumbnailfile: 'thumbnail.jpg', + videoChannelId: servers[1].videoChannel.id + }, + playlistId: playlistServer2Id2 + }) + + await waitJobs(servers) + + for (const server of servers) { + const res = await getVideoPlaylist(server.url, playlistServer2UUID2) + const playlist: VideoPlaylist = res.body + + expect(playlist.displayName).to.equal('playlist 3 updated') + expect(playlist.description).to.equal('description updated') + + expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.UNLISTED) + expect(playlist.privacy.label).to.equal('Unlisted') + + expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR) + expect(playlist.type.label).to.equal('Regular') + + expect(playlist.videosLength).to.equal(2) + + expect(playlist.ownerAccount.name).to.equal('root') + expect(playlist.ownerAccount.displayName).to.equal('root') + expect(playlist.videoChannel.name).to.equal('root_channel') + expect(playlist.videoChannel.displayName).to.equal('Main root channel') + } }) it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () { + this.timeout(30000) + + const addVideo = (elementAttrs: any) => { + return addVideoInPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistId: playlistServer1Id, elementAttrs }) + } + const res = await createVideoPlaylist({ + url: servers[ 0 ].url, + token: servers[ 0 ].accessToken, + playlistAttrs: { + displayName: 'playlist 4', + privacy: VideoPlaylistPrivacy.PUBLIC + } + }) + + playlistServer1Id = res.body.videoPlaylist.id + playlistServer1UUID = res.body.videoPlaylist.uuid + + await addVideo({ videoId: servers[0].videos[0].uuid, startTimestamp: 15, stopTimestamp: 28 }) + await addVideo({ videoId: servers[2].videos[1].uuid, startTimestamp: 35 }) + await addVideo({ videoId: servers[2].videos[2].uuid }) + await addVideo({ videoId: servers[0].videos[3].uuid, stopTimestamp: 35 }) + await addVideo({ videoId: servers[0].videos[4].uuid, startTimestamp: 45, stopTimestamp: 60 }) + await addVideo({ videoId: nsfwVideoServer1, startTimestamp: 5 }) + + await waitJobs(servers) }) it('Should correctly list playlist videos', async function () { - // empty - // some filters? + this.timeout(30000) + + for (const server of servers) { + const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) + + expect(res.body.total).to.equal(6) + + const videos: Video[] = res.body.data + expect(videos).to.have.lengthOf(6) + + expect(videos[0].name).to.equal('video 0 server 1') + expect(videos[0].playlistElement.position).to.equal(1) + expect(videos[0].playlistElement.startTimestamp).to.equal(15) + expect(videos[0].playlistElement.stopTimestamp).to.equal(28) + + expect(videos[1].name).to.equal('video 1 server 3') + expect(videos[1].playlistElement.position).to.equal(2) + expect(videos[1].playlistElement.startTimestamp).to.equal(35) + expect(videos[1].playlistElement.stopTimestamp).to.be.null + + expect(videos[2].name).to.equal('video 2 server 3') + expect(videos[2].playlistElement.position).to.equal(3) + expect(videos[2].playlistElement.startTimestamp).to.be.null + expect(videos[2].playlistElement.stopTimestamp).to.be.null + + expect(videos[3].name).to.equal('video 3 server 1') + expect(videos[3].playlistElement.position).to.equal(4) + expect(videos[3].playlistElement.startTimestamp).to.be.null + expect(videos[3].playlistElement.stopTimestamp).to.equal(35) + + expect(videos[4].name).to.equal('video 4 server 1') + expect(videos[4].playlistElement.position).to.equal(5) + expect(videos[4].playlistElement.startTimestamp).to.equal(45) + expect(videos[4].playlistElement.stopTimestamp).to.equal(60) + + expect(videos[5].name).to.equal('NSFW video') + expect(videos[5].playlistElement.position).to.equal(6) + expect(videos[5].playlistElement.startTimestamp).to.equal(5) + expect(videos[5].playlistElement.stopTimestamp).to.be.null + + const res2 = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10, { nsfw: false }) + expect(res2.body.total).to.equal(5) + expect(res2.body.data.find(v => v.name === 'NSFW video')).to.be.undefined + + const res3 = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 2) + expect(res3.body.data).to.have.lengthOf(2) + } }) it('Should reorder the playlist', async function () { - // reorder 1 element - // reorder 3 elements - // reorder at the beginning - // reorder at the end - // reorder before/after + this.timeout(30000) + + { + await reorderVideosPlaylist({ + url: servers[ 0 ].url, + token: servers[ 0 ].accessToken, + playlistId: playlistServer1Id, + elementAttrs: { + startPosition: 2, + insertAfterPosition: 3 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) + const names = res.body.data.map(v => v.name) + + expect(names).to.deep.equal([ + 'video 0 server 1', + 'video 2 server 3', + 'video 1 server 3', + 'video 3 server 1', + 'video 4 server 1', + 'NSFW video' + ]) + } + } + + { + await reorderVideosPlaylist({ + url: servers[0].url, + token: servers[0].accessToken, + playlistId: playlistServer1Id, + elementAttrs: { + startPosition: 1, + reorderLength: 3, + insertAfterPosition: 4 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) + const names = res.body.data.map(v => v.name) + + expect(names).to.deep.equal([ + 'video 3 server 1', + 'video 0 server 1', + 'video 2 server 3', + 'video 1 server 3', + 'video 4 server 1', + 'NSFW video' + ]) + } + } + + { + await reorderVideosPlaylist({ + url: servers[0].url, + token: servers[0].accessToken, + playlistId: playlistServer1Id, + elementAttrs: { + startPosition: 6, + insertAfterPosition: 3 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) + const videos: Video[] = res.body.data + + const names = videos.map(v => v.name) + + expect(names).to.deep.equal([ + 'video 3 server 1', + 'video 0 server 1', + 'video 2 server 3', + 'NSFW video', + 'video 1 server 3', + 'video 4 server 1' + ]) + + for (let i = 1; i <= videos.length; i++) { + expect(videos[i - 1].playlistElement.position).to.equal(i) + } + } + } }) it('Should update startTimestamp/endTimestamp of some elements', async function () { - + this.timeout(30000) + + await updateVideoPlaylistElement({ + url: servers[0].url, + token: servers[0].accessToken, + playlistId: playlistServer1Id, + videoId: servers[0].videos[3].uuid, + elementAttrs: { + startTimestamp: 1 + } + }) + + await updateVideoPlaylistElement({ + url: servers[0].url, + token: servers[0].accessToken, + playlistId: playlistServer1Id, + videoId: servers[0].videos[4].uuid, + elementAttrs: { + stopTimestamp: null + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) + const videos: Video[] = res.body.data + + expect(videos[0].name).to.equal('video 3 server 1') + expect(videos[0].playlistElement.position).to.equal(1) + expect(videos[0].playlistElement.startTimestamp).to.equal(1) + expect(videos[0].playlistElement.stopTimestamp).to.equal(35) + + expect(videos[5].name).to.equal('video 4 server 1') + expect(videos[5].playlistElement.position).to.equal(6) + expect(videos[5].playlistElement.startTimestamp).to.equal(45) + expect(videos[5].playlistElement.stopTimestamp).to.be.null + } }) it('Should delete some elements', async function () { + this.timeout(30000) + + await removeVideoFromPlaylist({ + url: servers[0].url, + token: servers[0].accessToken, + playlistId: playlistServer1Id, + videoId: servers[0].videos[3].uuid + }) + + await removeVideoFromPlaylist({ + url: servers[0].url, + token: servers[0].accessToken, + playlistId: playlistServer1Id, + videoId: nsfwVideoServer1 + }) + + await waitJobs(servers) + + for (const server of servers) { + const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) + + expect(res.body.total).to.equal(4) + + const videos: Video[] = res.body.data + expect(videos).to.have.lengthOf(4) + expect(videos[ 0 ].name).to.equal('video 0 server 1') + expect(videos[ 0 ].playlistElement.position).to.equal(1) + + expect(videos[ 1 ].name).to.equal('video 2 server 3') + expect(videos[ 1 ].playlistElement.position).to.equal(2) + + expect(videos[ 2 ].name).to.equal('video 1 server 3') + expect(videos[ 2 ].playlistElement.position).to.equal(3) + + expect(videos[ 3 ].name).to.equal('video 4 server 1') + expect(videos[ 3 ].playlistElement.position).to.equal(4) + } }) it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () { + this.timeout(30000) + await deleteVideoPlaylist(servers[0].url, servers[0].accessToken, playlistServer1Id) + + await waitJobs(servers) + + for (const server of servers) { + await getVideoPlaylist(server.url, playlistServer1UUID, 404) + } }) it('Should have deleted the thumbnail on server 1, 2 and 3', async function () { + this.timeout(30000) + for (const server of servers) { + await checkPlaylistFilesWereRemoved(playlistServer1UUID, server.serverNumber) + } }) it('Should unfollow servers 1 and 2 and hide their playlists', async function () { + this.timeout(30000) + const finder = data => data.find(p => p.displayName === 'my super playlist') + + { + const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5) + expect(res.body.total).to.equal(2) + expect(finder(res.body.data)).to.not.be.undefined + } + + await unfollow(servers[2].url, servers[2].accessToken, servers[0]) + + { + const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5) + expect(res.body.total).to.equal(1) + + expect(finder(res.body.data)).to.be.undefined + } }) - it('Should delete a channel and remove the associated playlist', async function () { + it('Should delete a channel and put the associated playlist in private mode', async function () { + this.timeout(30000) + + const res = await addVideoChannel(servers[0].url, servers[0].accessToken, { name: 'super_channel', displayName: 'super channel' }) + const videoChannelId = res.body.videoChannel.id + const res2 = await createVideoPlaylist({ + url: servers[0].url, + token: servers[0].accessToken, + playlistAttrs: { + displayName: 'channel playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId + } + }) + const videoPlaylistUUID = res2.body.videoPlaylist.uuid + + await waitJobs(servers) + + await deleteVideoChannel(servers[0].url, servers[0].accessToken, 'super_channel') + + await waitJobs(servers) + + const res3 = await getVideoPlaylistWithToken(servers[0].url, servers[0].accessToken, videoPlaylistUUID) + expect(res3.body.displayName).to.equal('channel playlist') + expect(res3.body.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE) + + await getVideoPlaylist(servers[1].url, videoPlaylistUUID, 404) }) it('Should delete an account and delete its playlists', async function () { + this.timeout(30000) + + const user = { username: 'user_1', password: 'password' } + const res = await createUser(servers[0].url, servers[0].accessToken, user.username, user.password) + + const userId = res.body.user.id + const userAccessToken = await userLogin(servers[0], user) + await createVideoPlaylist({ + url: servers[0].url, + token: userAccessToken, + playlistAttrs: { + displayName: 'playlist to be deleted', + privacy: VideoPlaylistPrivacy.PUBLIC + } + }) + + await waitJobs(servers) + + const finder = data => data.find(p => p.displayName === 'playlist to be deleted') + + { + for (const server of [ servers[0], servers[1] ]) { + const res = await getVideoPlaylistsList(server.url, 0, 15) + expect(finder(res.body.data)).to.not.be.undefined + } + } + + await removeUser(servers[0].url, userId, servers[0].accessToken) + await waitJobs(servers) + + { + for (const server of [ servers[0], servers[1] ]) { + const res = await getVideoPlaylistsList(server.url, 0, 15) + expect(finder(res.body.data)).to.be.undefined + } + } }) after(async function () { diff --git a/server/tests/fixtures/thumbnail-playlist.jpg b/server/tests/fixtures/thumbnail-playlist.jpg new file mode 100644 index 000000000..19db4f18c Binary files /dev/null and b/server/tests/fixtures/thumbnail-playlist.jpg differ -- cgit v1.2.3