audiobookshelf/server/controllers/PlaylistController.js

577 lines
20 KiB
JavaScript

const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/Playlist')} playlist
*
* @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest
*/
class PlaylistController {
constructor() {}
/**
* POST: /api/playlists
* Create playlist
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async create(req, res) {
const reqBody = req.body || {}
// Validation
if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid playlist data')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description')
}
const items = reqBody.items || []
const isPodcast = items.some((i) => i.episodeId)
const libraryItemIds = new Set()
for (const item of items) {
if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
return res.status(400).send('Invalid playlist item')
}
if (isPodcast && (!item.episodeId || typeof item.episodeId !== 'string')) {
return res.status(400).send('Invalid playlist item episodeId')
} else if (!isPodcast && item.episodeId) {
return res.status(400).send('Invalid playlist item episodeId')
}
libraryItemIds.add(item.libraryItemId)
}
// Load library items
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
where: {
id: Array.from(libraryItemIds),
libraryId: reqBody.libraryId,
mediaType: isPodcast ? 'podcast' : 'book'
}
})
if (libraryItems.length !== libraryItemIds.size) {
return res.status(400).send('Invalid playlist data. Invalid items')
}
// Validate podcast episodes
if (isPodcast) {
const podcastEpisodeIds = items.map((i) => i.episodeId)
const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
attributes: ['id'],
where: {
id: podcastEpisodeIds
}
})
if (podcastEpisodes.length !== podcastEpisodeIds.length) {
return res.status(400).send('Invalid playlist data. Invalid podcast episodes')
}
}
const transaction = await Database.sequelize.transaction()
try {
// Create playlist
const newPlaylist = await Database.playlistModel.create(
{
libraryId: reqBody.libraryId,
userId: req.user.id,
name: reqBody.name,
description: reqBody.description || null
},
{ transaction }
)
// Create playlistMediaItems
const playlistItemPayloads = []
for (const [index, item] of items.entries()) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
playlistItemPayloads.push({
playlistId: newPlaylist.id,
mediaItemId: item.episodeId || libraryItem.mediaId,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: index + 1
})
}
await Database.playlistMediaItemModel.bulkCreate(playlistItemPayloads, { transaction })
await transaction.commit()
newPlaylist.playlistMediaItems = await newPlaylist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = newPlaylist.toOldJSONExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
} catch (error) {
await transaction.rollback()
Logger.error('[PlaylistController] create:', error)
res.status(500).send('Failed to create playlist')
}
}
/**
* @deprecated - Use /api/libraries/:libraryId/playlists
* This is not used by Abs web client or mobile apps
* TODO: Remove this endpoint or refactor it and make it the primary
*
* GET: /api/playlists
* Get all playlists for user
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async findAllForUser(req, res) {
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id, req.params.libraryId)
res.json({
playlists: playlistsForUser
})
}
/**
* GET: /api/playlists/:id
*
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async findOne(req, res) {
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
res.json(req.playlist.toOldJSONExpanded())
}
/**
* PATCH: /api/playlists/:id
* Update playlist
*
* Used for updating name and description or reordering items
*
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async update(req, res) {
// Validation
const reqBody = req.body || {}
if (reqBody.libraryId || reqBody.userId) {
// Could allow support for this if needed with additional validation
return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')
}
if (reqBody.name && typeof reqBody.name !== 'string') {
return res.status(400).send('Invalid playlist name')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description')
}
if (reqBody.items && (!Array.isArray(reqBody.items) || reqBody.items.some((i) => !i.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string')))) {
return res.status(400).send('Invalid playlist items')
}
const playlistUpdatePayload = {}
if (reqBody.name) playlistUpdatePayload.name = reqBody.name
if (reqBody.description) playlistUpdatePayload.description = reqBody.description
// Update name and description
let wasUpdated = false
if (Object.keys(playlistUpdatePayload).length) {
req.playlist.set(playlistUpdatePayload)
const changed = req.playlist.changed()
if (changed?.length) {
await req.playlist.save()
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
wasUpdated = true
}
}
// If array of items is set then update order of playlist media items
if (reqBody.items?.length) {
const libraryItemIds = Array.from(new Set(reqBody.items.map((i) => i.libraryItemId)))
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType'],
where: {
id: libraryItemIds
}
})
if (libraryItems.length !== libraryItemIds.length) {
return res.status(400).send('Invalid playlist items. Items not found')
}
/** @type {import('../models/PlaylistMediaItem')[]} */
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
if (existingPlaylistMediaItems.length !== reqBody.items.length) {
return res.status(400).send('Invalid playlist items. Length mismatch')
}
// Set an array of mediaItemId
const newMediaItemIdOrder = []
for (const item of reqBody.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
const mediaItemId = item.episodeId || libraryItem.mediaId
newMediaItemIdOrder.push(mediaItemId)
}
// Sort existing playlist media items into new order
existingPlaylistMediaItems.sort((a, b) => {
const aIndex = newMediaItemIdOrder.findIndex((i) => i === a.mediaItemId)
const bIndex = newMediaItemIdOrder.findIndex((i) => i === b.mediaItemId)
return aIndex - bIndex
})
// Update order on playlistMediaItem records
for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {
if (playlistMediaItem.order !== index + 1) {
await playlistMediaItem.update({
order: index + 1
})
wasUpdated = true
}
}
}
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
if (wasUpdated) {
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
/**
* DELETE: /api/playlists/:id
* Remove playlist
*
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async delete(req, res) {
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200)
}
/**
* POST: /api/playlists/:id/item
* Add item to playlist
*
* This is not used by Abs web client or mobile apps. Only the batch endpoints are used.
*
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async addItem(req, res) {
const itemToAdd = req.body || {}
if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId')
}
const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
if (!libraryItem) {
return res.status(400).send('Library item not found')
}
if (libraryItem.libraryId !== req.playlist.libraryId) {
return res.status(400).send('Library item in different library')
}
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
return res.status(400).send('Invalid item to add for this library type')
}
if (itemToAdd.episodeId && !libraryItem.media.checkHasEpisode(itemToAdd.episodeId)) {
return res.status(400).send('Episode not found in library item')
}
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {
return res.status(400).send('Item already in playlist')
}
const jsonExpanded = req.playlist.toOldJSONExpanded()
const playlistMediaItem = {
playlistId: req.playlist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
order: req.playlist.playlistMediaItems.length + 1
}
await Database.playlistMediaItemModel.create(playlistMediaItem)
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
if (itemToAdd.episodeId) {
const episode = libraryItem.media.episodes.find((ep) => ep.id === itemToAdd.episodeId)
jsonExpanded.items.push({
episodeId: itemToAdd.episodeId,
episode: episode.toJSONExpanded(),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONMinified()
})
} else {
jsonExpanded.items.push({
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONExpanded()
})
}
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded)
}
/**
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
* Remove item from playlist
*
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async removeItem(req, res) {
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
let playlistMediaItem = null
if (req.params.episodeId) {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === req.params.episodeId)
} else {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === req.params.libraryItemId)
}
if (!playlistMediaItem) {
return res.status(404).send('Media item not found in playlist')
}
// Remove record
await playlistMediaItem.destroy()
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
// Update playlist media items order
for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
if (mediaItem.order !== index + 1) {
await mediaItem.update({
order: index + 1
})
}
}
const jsonExpanded = req.playlist.toOldJSONExpanded()
// Playlist is removed when there are no items
if (!jsonExpanded.items.length) {
Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
} else {
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
/**
* POST: /api/playlists/:id/batch/add
* Batch add playlist items
*
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async addBatch(req, res) {
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
return res.status(400).send('Invalid request body items')
}
// Find all library items
const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
const oldLibraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: Array.from(libraryItemIds) })
if (oldLibraryItems.length !== libraryItemIds.size) {
return res.status(400).send('Invalid request body items')
}
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const mediaItemsToAdd = []
const jsonExpanded = req.playlist.toOldJSONExpanded()
// Setup array of playlistMediaItem records to add
let order = req.playlist.playlistMediaItems.length + 1
for (const item of req.body.items) {
const libraryItem = oldLibraryItems.find((li) => li.id === item.libraryItemId)
const mediaItemId = item.episodeId || libraryItem.media.id
if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
// Already exists in playlist
continue
} else {
mediaItemsToAdd.push({
playlistId: req.playlist.id,
mediaItemId,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
})
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
if (item.episodeId) {
const episode = libraryItem.media.episodes.find((ep) => ep.id === item.episodeId)
jsonExpanded.items.push({
episodeId: item.episodeId,
episode: episode.toJSONExpanded(),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONMinified()
})
} else {
jsonExpanded.items.push({
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONExpanded()
})
}
}
}
if (mediaItemsToAdd.length) {
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
/**
* POST: /api/playlists/:id/batch/remove
* Batch remove playlist items
*
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async removeBatch(req, res) {
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
return res.status(400).send('Invalid request body items')
}
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
// Remove playlist media items
let hasUpdated = false
for (const item of req.body.items) {
let playlistMediaItem = null
if (item.episodeId) {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)
} else {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)
}
if (!playlistMediaItem) {
Logger.warn(`[PlaylistController] Playlist item not found in playlist ${req.playlist.id}`, item)
continue
}
await playlistMediaItem.destroy()
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
hasUpdated = true
}
const jsonExpanded = req.playlist.toOldJSONExpanded()
if (hasUpdated) {
// Playlist is removed when there are no items
if (!req.playlist.playlistMediaItems.length) {
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
} else {
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
}
}
res.json(jsonExpanded)
}
/**
* POST: /api/playlists/collection/:collectionId
* Create a playlist from a collection
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async createFromCollection(req, res) {
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) {
return res.status(404).send('Collection not found')
}
// Expand collection to get library items
const collectionExpanded = await collection.getOldJsonExpanded(req.user)
if (!collectionExpanded) {
// This can happen if the user has no access to all items in collection
return res.status(404).send('Collection not found')
}
// Playlists cannot be empty
if (!collectionExpanded.books.length) {
return res.status(400).send('Collection has no books')
}
const transaction = await Database.sequelize.transaction()
try {
const playlist = await Database.playlistModel.create(
{
userId: req.user.id,
libraryId: collection.libraryId,
name: collection.name,
description: collection.description || null
},
{ transaction }
)
const mediaItemsToAdd = []
for (const [index, libraryItem] of collectionExpanded.books.entries()) {
mediaItemsToAdd.push({
playlistId: playlist.id,
mediaItemId: libraryItem.media.id,
mediaItemType: 'book',
order: index + 1
})
}
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd, { transaction })
await transaction.commit()
playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = playlist.toOldJSONExpanded()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
} catch (error) {
await transaction.rollback()
Logger.error('[PlaylistController] createFromCollection:', error)
res.status(500).send('Failed to create playlist')
}
}
/**
*
* @param {RequestWithUser} req
* @param {Response} res
* @param {NextFunction} next
*/
async middleware(req, res, next) {
if (req.params.id) {
const playlist = await Database.playlistModel.findByPk(req.params.id)
if (!playlist) {
return res.status(404).send('Playlist not found')
}
if (playlist.userId !== req.user.id) {
Logger.warn(`[PlaylistController] Playlist ${req.params.id} requested by user ${req.user.id} that is not the owner`)
return res.sendStatus(403)
}
req.playlist = playlist
}
next()
}
}
module.exports = new PlaylistController()