Update Playlist model & controller to remove usage of old Playlist object, remove old Playlist

This commit is contained in:
advplyr 2024-12-31 17:01:42 -06:00
parent 6780ef9b37
commit 9785bc02ea
7 changed files with 501 additions and 513 deletions

View File

@ -406,16 +406,6 @@ class Database {
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
} }
createPlaylistMediaItem(playlistMediaItem) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.create(playlistMediaItem)
}
createBulkPlaylistMediaItems(playlistMediaItems) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
}
async createLibraryItem(oldLibraryItem) { async createLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false if (!this.sequelize) return false
await oldLibraryItem.saveMetadata() await oldLibraryItem.saveMetadata()

View File

@ -35,6 +35,9 @@ class CollectionController {
if (!reqBody.name || !reqBody.libraryId) { if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid collection data') return res.status(400).send('Invalid collection data')
} }
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid collection description')
}
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string') const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
if (!libraryItemIds.length) { if (!libraryItemIds.length) {
return res.status(400).send('Invalid collection data. No books') return res.status(400).send('Invalid collection data. No books')

View File

@ -3,13 +3,16 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const Playlist = require('../objects/Playlist')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/Playlist')} playlist
*
* @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest
*/ */
class PlaylistController { class PlaylistController {
@ -23,48 +26,103 @@ class PlaylistController {
* @param {Response} res * @param {Response} res
*/ */
async create(req, res) { async create(req, res) {
const oldPlaylist = new Playlist() const reqBody = req.body || {}
req.body.userId = req.user.id
const success = oldPlaylist.setData(req.body) // Validation
if (!success) { if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid playlist request data') 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)
} }
// Create Playlist record // Load library items
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist) const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
// Lookup all library items in playlist
const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId).filter((i) => i)
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
where: { where: {
id: libraryItemIds 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')
}
// Create playlistMediaItem records // Validate podcast episodes
const mediaItemsToAdd = [] if (isPodcast) {
let order = 1 const podcastEpisodeIds = items.map((i) => i.episodeId)
for (const mediaItemObj of oldPlaylist.items) { const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
const libraryItem = libraryItemsInPlaylist.find((li) => li.id === mediaItemObj.libraryItemId) attributes: ['id'],
if (!libraryItem) continue where: {
id: podcastEpisodeIds
mediaItemsToAdd.push({ }
mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
playlistId: oldPlaylist.id,
order: order++
}) })
} if (podcastEpisodes.length !== podcastEpisodeIds.length) {
if (mediaItemsToAdd.length) { return res.status(400).send('Invalid playlist data. Invalid podcast episodes')
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd) }
} }
const jsonExpanded = await newPlaylist.getOldJsonExpanded() const transaction = await Database.sequelize.transaction()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded) try {
res.json(jsonExpanded) // 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: /api/playlists
* Get all playlists for user * Get all playlists for user
* *
@ -72,68 +130,89 @@ class PlaylistController {
* @param {Response} res * @param {Response} res
*/ */
async findAllForUser(req, res) { async findAllForUser(req, res) {
const playlistsForUser = await Database.playlistModel.findAll({ const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id, req.params.libraryId)
where: {
userId: req.user.id
}
})
const playlists = []
for (const playlist of playlistsForUser) {
const jsonExpanded = await playlist.getOldJsonExpanded()
playlists.push(jsonExpanded)
}
res.json({ res.json({
playlists playlists: playlistsForUser
}) })
} }
/** /**
* GET: /api/playlists/:id * GET: /api/playlists/:id
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async findOne(req, res) { async findOne(req, res) {
const jsonExpanded = await req.playlist.getOldJsonExpanded() req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
res.json(jsonExpanded) res.json(req.playlist.toOldJSONExpanded())
} }
/** /**
* PATCH: /api/playlists/:id * PATCH: /api/playlists/:id
* Update playlist * Update playlist
* *
* @param {RequestWithUser} req * Used for updating name and description or reordering items
*
* @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async update(req, res) { async update(req, res) {
const updatedPlaylist = req.playlist.set(req.body) // Validation
let wasUpdated = false const reqBody = req.body || {}
const changed = updatedPlaylist.changed() if (reqBody.libraryId || reqBody.userId) {
if (changed?.length) { // Could allow support for this if needed with additional validation
await req.playlist.save() return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`) }
wasUpdated = true 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')
} }
// If array of items is passed in then update order of playlist media items const playlistUpdatePayload = {}
const libraryItemIds = req.body.items?.map((i) => i.libraryItemId).filter((i) => i) || [] if (reqBody.name) playlistUpdatePayload.name = reqBody.name
if (libraryItemIds.length) { 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({ const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType'],
where: { where: {
id: libraryItemIds id: libraryItemIds
} }
}) })
const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({ 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']] order: [['order', 'ASC']]
}) })
if (existingPlaylistMediaItems.length !== reqBody.items.length) {
return res.status(400).send('Invalid playlist items. Length mismatch')
}
// Set an array of mediaItemId // Set an array of mediaItemId
const newMediaItemIdOrder = [] const newMediaItemIdOrder = []
for (const item of req.body.items) { for (const item of reqBody.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) {
continue
}
const mediaItemId = item.episodeId || libraryItem.mediaId const mediaItemId = item.episodeId || libraryItem.mediaId
newMediaItemIdOrder.push(mediaItemId) newMediaItemIdOrder.push(mediaItemId)
} }
@ -146,21 +225,21 @@ class PlaylistController {
}) })
// Update order on playlistMediaItem records // Update order on playlistMediaItem records
let order = 1 for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {
for (const playlistMediaItem of existingPlaylistMediaItems) { if (playlistMediaItem.order !== index + 1) {
if (playlistMediaItem.order !== order) {
await playlistMediaItem.update({ await playlistMediaItem.update({
order order: index + 1
}) })
wasUpdated = true wasUpdated = true
} }
order++
} }
} }
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded() req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
if (wasUpdated) { if (wasUpdated) {
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@ -169,11 +248,13 @@ class PlaylistController {
* DELETE: /api/playlists/:id * DELETE: /api/playlists/:id
* Remove playlist * Remove playlist
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async delete(req, res) { async delete(req, res) {
const jsonExpanded = await req.playlist.getOldJsonExpanded() req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
await req.playlist.destroy() await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200) res.sendStatus(200)
@ -183,12 +264,13 @@ class PlaylistController {
* POST: /api/playlists/:id/item * POST: /api/playlists/:id/item
* Add item to playlist * Add item to playlist
* *
* @param {RequestWithUser} req * This is not used by Abs web client or mobile apps. Only the batch endpoints are used.
*
* @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async addItem(req, res) { async addItem(req, res) {
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id) const itemToAdd = req.body || {}
const itemToAdd = req.body
if (!itemToAdd.libraryItemId) { if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId') return res.status(400).send('Request body has no libraryItemId')
@ -198,12 +280,9 @@ class PlaylistController {
if (!libraryItem) { if (!libraryItem) {
return res.status(400).send('Library item not found') return res.status(400).send('Library item not found')
} }
if (libraryItem.libraryId !== oldPlaylist.libraryId) { if (libraryItem.libraryId !== req.playlist.libraryId) {
return res.status(400).send('Library item in different library') return res.status(400).send('Library item in different library')
} }
if (oldPlaylist.containsItem(itemToAdd)) {
return res.status(400).send('Item already in playlist')
}
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) { if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
return res.status(400).send('Invalid item to add for this library type') return res.status(400).send('Invalid item to add for this library type')
} }
@ -211,15 +290,38 @@ class PlaylistController {
return res.status(400).send('Episode not found in library item') return res.status(400).send('Episode not found in library item')
} }
const playlistMediaItem = { req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
playlistId: oldPlaylist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id, if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book', return res.status(400).send('Item already in playlist')
order: oldPlaylist.items.length + 1 }
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()
})
} }
await Database.createPlaylistMediaItem(playlistMediaItem)
const jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@ -228,43 +330,36 @@ class PlaylistController {
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId? * DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
* Remove item from playlist * Remove item from playlist
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeItem(req, res) { async removeItem(req, res) {
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId) req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
if (!oldLibraryItem) {
return res.status(404).send('Library item not found') 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) {
// Get playlist media items
const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
// Check if media item to delete is in playlist
const mediaItemToRemove = playlistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
if (!mediaItemToRemove) {
return res.status(404).send('Media item not found in playlist') return res.status(404).send('Media item not found in playlist')
} }
// Remove record // Remove record
await mediaItemToRemove.destroy() await playlistMediaItem.destroy()
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
// Update playlist media items order // Update playlist media items order
let order = 1 for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
for (const mediaItem of playlistMediaItems) { if (mediaItem.order !== index + 1) {
if (mediaItem.mediaItemId === mediaItemId) continue
if (mediaItem.order !== order) {
await mediaItem.update({ await mediaItem.update({
order order: index + 1
}) })
} }
order++
} }
const jsonExpanded = await req.playlist.getOldJsonExpanded() const jsonExpanded = req.playlist.toOldJSONExpanded()
// Playlist is removed when there are no items // Playlist is removed when there are no items
if (!jsonExpanded.items.length) { if (!jsonExpanded.items.length) {
@ -282,64 +377,68 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/add * POST: /api/playlists/:id/batch/add
* Batch add playlist items * Batch add playlist items
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async addBatch(req, res) { async addBatch(req, res) {
if (!req.body.items?.length) { 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') return res.status(400).send('Invalid request body items')
}
const itemsToAdd = req.body.items
const libraryItemIds = itemsToAdd.map((i) => i.libraryItemId).filter((i) => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
} }
// Find all library items // Find all library items
const libraryItems = await Database.libraryItemModel.findAll({ const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
where: {
id: libraryItemIds
}
})
// Get all existing playlist media items const oldLibraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: Array.from(libraryItemIds) })
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({ if (oldLibraryItems.length !== libraryItemIds.size) {
order: [['order', 'ASC']] return res.status(400).send('Invalid request body items')
}) }
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const mediaItemsToAdd = [] const mediaItemsToAdd = []
const jsonExpanded = req.playlist.toOldJSONExpanded()
// Setup array of playlistMediaItem records to add // Setup array of playlistMediaItem records to add
let order = existingPlaylistMediaItems.length + 1 let order = req.playlist.playlistMediaItems.length + 1
for (const item of itemsToAdd) { for (const item of req.body.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) const libraryItem = oldLibraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) {
return res.status(404).send('Item not found with 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 { } else {
const mediaItemId = item.episodeId || libraryItem.mediaId mediaItemsToAdd.push({
if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) { playlistId: req.playlist.id,
// Already exists in playlist mediaItemId,
continue 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 { } else {
mediaItemsToAdd.push({ jsonExpanded.items.push({
playlistId: req.playlist.id, libraryItemId: libraryItem.id,
mediaItemId, libraryItem: libraryItem.toJSONExpanded()
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
}) })
} }
} }
} }
let jsonExpanded = null
if (mediaItemsToAdd.length) { if (mediaItemsToAdd.length) {
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd) await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
} else {
jsonExpanded = await req.playlist.getOldJsonExpanded()
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@ -347,50 +446,40 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/remove * POST: /api/playlists/:id/batch/remove
* Batch remove playlist items * Batch remove playlist items
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeBatch(req, res) { async removeBatch(req, res) {
if (!req.body.items?.length) { 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') return res.status(400).send('Invalid request body items')
} }
const itemsToRemove = req.body.items req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const libraryItemIds = itemsToRemove.map((i) => i.libraryItemId).filter((i) => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
}
// Find all library items
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
})
// Get all existing playlist media items for playlist
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
let numMediaItems = existingPlaylistMediaItems.length
// Remove playlist media items // Remove playlist media items
let hasUpdated = false let hasUpdated = false
for (const item of itemsToRemove) { for (const item of req.body.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) let playlistMediaItem = null
if (!libraryItem) continue if (item.episodeId) {
const mediaItemId = item.episodeId || libraryItem.mediaId playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)
const existingMediaItem = existingPlaylistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId) } else {
if (!existingMediaItem) continue playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)
await existingMediaItem.destroy() }
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 hasUpdated = true
numMediaItems--
} }
const jsonExpanded = await req.playlist.getOldJsonExpanded() const jsonExpanded = req.playlist.toOldJSONExpanded()
if (hasUpdated) { if (hasUpdated) {
// Playlist is removed when there are no items // Playlist is removed when there are no items
if (!numMediaItems) { if (!req.playlist.playlistMediaItems.length) {
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`) Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
await req.playlist.destroy() await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
@ -425,33 +514,41 @@ class PlaylistController {
return res.status(400).send('Collection has no books') return res.status(400).send('Collection has no books')
} }
const oldPlaylist = new Playlist() const transaction = await Database.sequelize.transaction()
oldPlaylist.setData({ try {
userId: req.user.id, const playlist = await Database.playlistModel.create(
libraryId: collection.libraryId, {
name: collection.name, userId: req.user.id,
description: collection.description || null libraryId: collection.libraryId,
}) name: collection.name,
description: collection.description || null
},
{ transaction }
)
// Create Playlist record const mediaItemsToAdd = []
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist) 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 })
// Create PlaylistMediaItem records await transaction.commit()
const mediaItemsToAdd = []
let order = 1 playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()
for (const libraryItem of collectionExpanded.books) {
mediaItemsToAdd.push({ const jsonExpanded = playlist.toOldJSONExpanded()
playlistId: newPlaylist.id, SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)
mediaItemId: libraryItem.media.id, res.json(jsonExpanded)
mediaItemType: 'book', } catch (error) {
order: order++ await transaction.rollback()
}) Logger.error('[PlaylistController] createFromCollection:', error)
res.status(500).send('Failed to create playlist')
} }
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
} }
/** /**

View File

@ -1,8 +1,6 @@
const { DataTypes, Model, Op, literal } = require('sequelize') const { DataTypes, Model, Op } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldPlaylist = require('../objects/Playlist')
class Playlist extends Model { class Playlist extends Model {
constructor(values, options) { constructor(values, options) {
super(values, options) super(values, options)
@ -21,134 +19,23 @@ class Playlist extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
}
static getOldPlaylist(playlistExpanded) { // Expanded properties
const items = playlistExpanded.playlistMediaItems
.map((pmi) => {
const mediaItem = pmi.mediaItem || pmi.dataValues?.mediaItem
const libraryItemId = mediaItem?.podcast?.libraryItem?.id || mediaItem?.libraryItem?.id || null
if (!libraryItemId) {
Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2))
return null
}
return {
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '',
libraryItemId
}
})
.filter((pmi) => pmi)
return new oldPlaylist({ /** @type {import('./PlaylistMediaItem')[]} - only set when expanded */
id: playlistExpanded.id, this.playlistMediaItems
libraryId: playlistExpanded.libraryId,
userId: playlistExpanded.userId,
name: playlistExpanded.name,
description: playlistExpanded.description,
items,
lastUpdate: playlistExpanded.updatedAt.valueOf(),
createdAt: playlistExpanded.createdAt.valueOf()
})
} }
/** /**
* Get old playlist toJSONExpanded * Get old playlists for user and library
* @param {string[]} [include]
* @returns {Promise<oldPlaylist>} oldPlaylist.toJSONExpanded
*/
async getOldJsonExpanded(include) {
this.playlistMediaItems =
(await this.getPlaylistMediaItems({
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: {
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
}
],
order: [['order', 'ASC']]
})) || []
const oldPlaylist = this.sequelize.models.playlist.getOldPlaylist(this)
const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId)
let libraryItems = await this.sequelize.models.libraryItem.getAllOldLibraryItems({
id: libraryItemIds
})
const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems)
return playlistExpanded
}
static createFromOld(oldPlaylist) {
const playlist = this.getFromOld(oldPlaylist)
return this.create(playlist)
}
static getFromOld(oldPlaylist) {
return {
id: oldPlaylist.id,
name: oldPlaylist.name,
description: oldPlaylist.description,
userId: oldPlaylist.userId,
libraryId: oldPlaylist.libraryId
}
}
static removeById(playlistId) {
return this.destroy({
where: {
id: playlistId
}
})
}
/**
* Get playlist by id
* @param {string} playlistId
* @returns {Promise<oldPlaylist|null>} returns null if not found
*/
static async getById(playlistId) {
if (!playlistId) return null
const playlist = await this.findByPk(playlistId, {
include: {
model: this.sequelize.models.playlistMediaItem,
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: {
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
}
]
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
if (!playlist) return null
return this.getOldPlaylist(playlist)
}
/**
* Get old playlists for user and optionally for library
* *
* @param {string} userId * @param {string} userId
* @param {string} [libraryId] * @param {string} libraryId
* @returns {Promise<oldPlaylist[]>} * @async
*/ */
static async getOldPlaylistsForUserAndLibrary(userId, libraryId = null) { static async getOldPlaylistsForUserAndLibrary(userId, libraryId) {
if (!userId && !libraryId) return [] if (!userId && !libraryId) return []
const whereQuery = {} const whereQuery = {}
if (userId) { if (userId) {
whereQuery.userId = userId whereQuery.userId = userId
@ -163,7 +50,23 @@ class Playlist extends Model {
include: [ include: [
{ {
model: this.sequelize.models.book, model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
}, },
{ {
model: this.sequelize.models.podcastEpisode, model: this.sequelize.models.podcastEpisode,
@ -174,42 +77,13 @@ class Playlist extends Model {
} }
] ]
}, },
order: [ order: [['playlistMediaItems', 'order', 'ASC']]
[literal('name COLLATE NOCASE'), 'ASC'],
['playlistMediaItems', 'order', 'ASC']
]
}) })
const oldPlaylists = [] // Sort by name asc
for (const playlistExpanded of playlistsExpanded) { playlistsExpanded.sort((a, b) => a.name.localeCompare(b.name))
const oldPlaylist = this.getOldPlaylist(playlistExpanded)
const libraryItems = []
for (const pmi of playlistExpanded.playlistMediaItems) {
let mediaItem = pmi.mediaItem || pmi.dataValues.mediaItem
if (!mediaItem) { return playlistsExpanded.map((playlist) => playlist.toOldJSONExpanded())
Logger.error(`[Playlist] Invalid playlist media item - No media item found`, JSON.stringify(mediaItem, null, 2))
continue
}
let libraryItem = mediaItem.libraryItem || mediaItem.podcast?.libraryItem
if (mediaItem.podcast) {
libraryItem.media = mediaItem.podcast
libraryItem.media.podcastEpisodes = [mediaItem]
delete mediaItem.podcast.libraryItem
} else {
libraryItem.media = mediaItem
delete mediaItem.libraryItem
}
const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
libraryItems.push(oldLibraryItem)
}
const oldPlaylistJson = oldPlaylist.toJSONExpanded(libraryItems)
oldPlaylists.push(oldPlaylistJson)
}
return oldPlaylists
} }
/** /**
@ -345,6 +219,117 @@ class Playlist extends Model {
} }
}) })
} }
/**
* Get all media items in playlist expanded with library item
*
* @returns {Promise<import('./PlaylistMediaItem')[]>}
*/
getMediaItemsExpandedWithLibraryItem() {
return this.getPlaylistMediaItems({
include: [
{
model: this.sequelize.models.book,
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
},
{
model: this.sequelize.models.podcastEpisode,
include: [
{
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
]
}
],
order: [['order', 'ASC']]
})
}
/**
* Get playlists toOldJSONExpanded
*
* @async
*/
async getOldJsonExpanded() {
this.playlistMediaItems = await this.getMediaItemsExpandedWithLibraryItem()
return this.toOldJSONExpanded()
}
/**
* Old model used libraryItemId instead of bookId
*
* @param {string} libraryItemId
* @param {string} [episodeId]
*/
checkHasMediaItem(libraryItemId, episodeId) {
if (!this.playlistMediaItems) {
throw new Error('playlistMediaItems are required to check Playlist')
}
if (episodeId) {
return this.playlistMediaItems.some((pmi) => pmi.mediaItemId === episodeId)
}
return this.playlistMediaItems.some((pmi) => pmi.mediaItem.libraryItem.id === libraryItemId)
}
toOldJSON() {
return {
id: this.id,
name: this.name,
libraryId: this.libraryId,
userId: this.userId,
description: this.description,
lastUpdate: this.updatedAt.valueOf(),
createdAt: this.createdAt.valueOf()
}
}
toOldJSONExpanded() {
if (!this.playlistMediaItems) {
throw new Error('playlistMediaItems are required to expand Playlist')
}
const json = this.toOldJSON()
json.items = this.playlistMediaItems.map((pmi) => {
if (pmi.mediaItemType === 'book') {
const libraryItem = pmi.mediaItem.libraryItem
delete pmi.mediaItem.libraryItem
libraryItem.media = pmi.mediaItem
return {
libraryItemId: libraryItem.id,
libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded()
}
}
const libraryItem = pmi.mediaItem.podcast.libraryItem
delete pmi.mediaItem.podcast.libraryItem
libraryItem.media = pmi.mediaItem.podcast
return {
episodeId: pmi.mediaItemId,
episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id,
libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified()
}
})
return json
}
} }
module.exports = Playlist module.exports = Playlist

View File

@ -16,6 +16,11 @@ class PlaylistMediaItem extends Model {
this.playlistId this.playlistId
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
// Expanded properties
/** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */
this.mediaItem
} }
static removeByIds(playlistId, mediaItemId) { static removeByIds(playlistId, mediaItemId) {

View File

@ -170,6 +170,62 @@ class PodcastEpisode extends Model {
}) })
PodcastEpisode.belongsTo(podcast) PodcastEpisode.belongsTo(podcast)
} }
/**
* AudioTrack object used in old model
*
* @returns {import('./Book').AudioFileObject|null}
*/
get track() {
if (!this.audioFile) return null
const track = structuredClone(this.audioFile)
track.startOffset = 0
track.title = this.audioFile.metadata.title
return track
}
toOldJSON(libraryItemId) {
let enclosure = null
if (this.enclosureURL) {
enclosure = {
url: this.enclosureURL,
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
}
}
return {
libraryItemId: libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure,
guid: this.extraData?.guid || null,
pubDate: this.pubDate,
chapters: this.chapters?.map((ch) => ({ ...ch })) || [],
audioFile: this.audioFile || null,
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
}
}
toOldJSONExpanded(libraryItemId) {
const json = this.toOldJSON(libraryItemId)
json.audioTrack = this.track
json.size = this.audioFile?.metadata.size || 0
json.duration = this.audioFile?.duration || 0
return json
}
} }
module.exports = PodcastEpisode module.exports = PodcastEpisode

View File

@ -1,148 +0,0 @@
const uuidv4 = require("uuid").v4
class Playlist {
constructor(playlist) {
this.id = null
this.libraryId = null
this.userId = null
this.name = null
this.description = null
this.coverPath = null
// Array of objects like { libraryItemId: "", episodeId: "" } (episodeId optional)
this.items = []
this.lastUpdate = null
this.createdAt = null
if (playlist) {
this.construct(playlist)
}
}
toJSON() {
return {
id: this.id,
libraryId: this.libraryId,
userId: this.userId,
name: this.name,
description: this.description,
coverPath: this.coverPath,
items: [...this.items],
lastUpdate: this.lastUpdate,
createdAt: this.createdAt
}
}
// Expands the items array
toJSONExpanded(libraryItems) {
var json = this.toJSON()
json.items = json.items.map(item => {
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
if (!libraryItem) {
// Not found
return null
}
if (item.episodeId) {
if (!libraryItem.isPodcast) {
// Invalid
return null
}
const episode = libraryItem.media.episodes.find(ep => ep.id === item.episodeId)
if (!episode) {
// Not found
return null
}
return {
...item,
episode: episode.toJSONExpanded(),
libraryItem: libraryItem.toJSONMinified()
}
} else {
return {
...item,
libraryItem: libraryItem.toJSONExpanded()
}
}
}).filter(i => i)
return json
}
construct(playlist) {
this.id = playlist.id
this.libraryId = playlist.libraryId
this.userId = playlist.userId
this.name = playlist.name
this.description = playlist.description || null
this.coverPath = playlist.coverPath || null
this.items = playlist.items ? playlist.items.map(i => ({ ...i })) : []
this.lastUpdate = playlist.lastUpdate || null
this.createdAt = playlist.createdAt || null
}
setData(data) {
if (!data.userId || !data.libraryId || !data.name) {
return false
}
this.id = uuidv4()
this.userId = data.userId
this.libraryId = data.libraryId
this.name = data.name
this.description = data.description || null
this.coverPath = data.coverPath || null
this.items = data.items ? data.items.map(i => ({ ...i })) : []
this.lastUpdate = Date.now()
this.createdAt = Date.now()
return true
}
addItem(libraryItemId, episodeId = null) {
this.items.push({
libraryItemId,
episodeId: episodeId || null
})
this.lastUpdate = Date.now()
}
removeItem(libraryItemId, episodeId = null) {
if (episodeId) this.items = this.items.filter(i => i.libraryItemId !== libraryItemId || i.episodeId !== episodeId)
else this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
this.lastUpdate = Date.now()
}
update(payload) {
let hasUpdates = false
for (const key in payload) {
if (key === 'items') {
if (payload.items && JSON.stringify(payload.items) !== JSON.stringify(this.items)) {
this.items = payload.items.map(i => ({ ...i }))
hasUpdates = true
}
} else if (this[key] !== undefined && this[key] !== payload[key]) {
hasUpdates = true
this[key] = payload[key]
}
}
if (hasUpdates) {
this.lastUpdate = Date.now()
}
return hasUpdates
}
containsItem(item) {
if (item.episodeId) return this.items.some(i => i.libraryItemId === item.libraryItemId && i.episodeId === item.episodeId)
return this.items.some(i => i.libraryItemId === item.libraryItemId)
}
hasItemsForLibraryItem(libraryItemId) {
return this.items.some(i => i.libraryItemId === libraryItemId)
}
removeItemsForLibraryItem(libraryItemId) {
this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
}
}
module.exports = Playlist