From 84e60067083a3e8dc9c3fb8fb3ce7aa6cb800663 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 24 Mar 2026 22:32:43 -0400 Subject: [PATCH] Support series in playlists and collections --- server/Database.js | 38 +- server/controllers/CollectionController.js | 210 +++++-- server/controllers/PlaylistController.js | 195 ++++-- ...4.0-add-series-to-playlists-collections.js | 107 ++++ server/models/Collection.js | 53 +- server/models/CollectionSeriesItem.js | 54 ++ server/models/Playlist.js | 150 ++++- server/models/PlaylistMediaItem.js | 18 +- server/routers/ApiRouter.js | 13 +- ...CollectionController.seriesEntries.test.js | 431 +++++++++++++ .../PlaylistController.seriesEntries.test.js | 585 ++++++++++++++++++ 11 files changed, 1750 insertions(+), 104 deletions(-) create mode 100644 server/migrations/v2.34.0-add-series-to-playlists-collections.js create mode 100644 server/models/CollectionSeriesItem.js create mode 100644 test/server/controllers/CollectionController.seriesEntries.test.js create mode 100644 test/server/controllers/PlaylistController.seriesEntries.test.js diff --git a/server/Database.js b/server/Database.js index 213c2c61b..7e32d5c0e 100644 --- a/server/Database.js +++ b/server/Database.js @@ -162,6 +162,11 @@ class Database { return this.models.device } + /** @type {typeof import('./models/CollectionSeriesItem')} */ + get collectionSeriesItemModel() { + return this.models.collectionSeriesItem + } + /** * Check if db file exists * @returns {boolean} @@ -336,6 +341,7 @@ class Database { require('./models/BookAuthor').init(this.sequelize) require('./models/Collection').init(this.sequelize) require('./models/CollectionBook').init(this.sequelize) + require('./models/CollectionSeriesItem').init(this.sequelize) require('./models/Playlist').init(this.sequelize) require('./models/PlaylistMediaItem').init(this.sequelize) require('./models/Device').init(this.sequelize) @@ -721,7 +727,7 @@ class Database { await libraryItem.destroy() } - // Remove invalid PlaylistMediaItem records + // Remove invalid PlaylistMediaItem records (book/podcastEpisode types) const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({ include: [ { @@ -734,6 +740,7 @@ class Database { } ], where: { + mediaItemType: ['book', 'podcastEpisode'], '$book.id$': null, '$podcastEpisode.id$': null } @@ -743,6 +750,22 @@ class Database { await playlistMediaItem.destroy() } + // Remove invalid PlaylistMediaItem records (series type) + const playlistSeriesItemsWithNoSeries = await this.playlistMediaItemModel.findAll({ + include: { + model: this.seriesModel, + attributes: ['id'] + }, + where: { + mediaItemType: 'series', + '$series.id$': null + } + }) + for (const playlistMediaItem of playlistSeriesItemsWithNoSeries) { + Logger.warn(`Found playlistMediaItem with no series - removing it`) + await playlistMediaItem.destroy() + } + // Remove invalid CollectionBook records const collectionBooksWithNoBook = await this.collectionBookModel.findAll({ include: { @@ -756,6 +779,19 @@ class Database { await collectionBook.destroy() } + // Remove invalid CollectionSeriesItem records + const collectionSeriesItemsWithNoSeries = await this.collectionSeriesItemModel.findAll({ + include: { + model: this.seriesModel, + required: false + }, + where: { '$series.id$': null } + }) + for (const collectionSeriesItem of collectionSeriesItemsWithNoSeries) { + Logger.warn(`Found collectionSeriesItem with no series - removing it`) + await collectionSeriesItem.destroy() + } + // Remove empty series const emptySeries = await this.seriesModel.findAll({ include: { diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js index 1476b0f81..e7218dbc8 100644 --- a/server/controllers/CollectionController.js +++ b/server/controllers/CollectionController.js @@ -42,21 +42,42 @@ class CollectionController { return res.status(400).send('Invalid collection description') } const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string') - if (!libraryItemIds.length) { - return res.status(400).send('Invalid collection data. No books') + const seriesIds = (reqBody.seriesIds || []).filter((id) => !!id && typeof id === 'string') + const uniqueSeriesIds = [...new Set(seriesIds)] + + // Must have at least one book or series + if (!libraryItemIds.length && !uniqueSeriesIds.length) { + return res.status(400).send('Invalid collection data. No books or series') } - // Load library items - const libraryItems = await Database.libraryItemModel.findAll({ - attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], - where: { - id: libraryItemIds, - libraryId: reqBody.libraryId, - mediaType: 'book' + // Load and validate library items + let libraryItems = [] + if (libraryItemIds.length) { + libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], + where: { + id: libraryItemIds, + libraryId: reqBody.libraryId, + mediaType: 'book' + } + }) + if (libraryItems.length !== libraryItemIds.length) { + return res.status(400).send('Invalid collection data. Invalid books') + } + } + + // Validate series exist in the same library + if (uniqueSeriesIds.length) { + const seriesRecords = await Database.seriesModel.findAll({ + attributes: ['id'], + where: { + id: uniqueSeriesIds, + libraryId: reqBody.libraryId + } + }) + if (seriesRecords.length !== uniqueSeriesIds.length) { + return res.status(400).send('Invalid collection data. Invalid series') } - }) - if (libraryItems.length !== libraryItemIds.length) { - return res.status(400).send('Invalid collection data. Invalid books') } /** @type {import('../models/Collection')} */ @@ -75,15 +96,28 @@ class CollectionController { ) // Create collectionBooks - const collectionBookPayloads = libraryItemIds.map((llid, index) => { - const libraryItem = libraryItems.find((li) => li.id === llid) - return { + let order = 1 + if (libraryItemIds.length) { + const collectionBookPayloads = libraryItemIds.map((llid) => { + const libraryItem = libraryItems.find((li) => li.id === llid) + return { + collectionId: newCollection.id, + bookId: libraryItem.mediaId, + order: order++ + } + }) + await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction }) + } + + // Create collectionSeriesItems + if (uniqueSeriesIds.length) { + const collectionSeriesPayloads = uniqueSeriesIds.map((seriesId) => ({ collectionId: newCollection.id, - bookId: libraryItem.mediaId, - order: index + 1 - } - }) - await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction }) + seriesId, + order: order++ + })) + await Database.collectionSeriesItemModel.bulkCreate(collectionSeriesPayloads, { transaction }) + } await transaction.commit() } catch (error) { @@ -92,10 +126,10 @@ class CollectionController { return res.status(500).send('Failed to create collection') } - // Load books expanded + // Load books and series expanded newCollection.books = await newCollection.getBooksExpandedWithLibraryItem() + newCollection.collectionSeriesItems = await newCollection.getSeriesItemsExpanded() - // Note: The old collection model stores expanded libraryItems in the books property const jsonExpanded = newCollection.toOldJSONExpanded() SocketAuthority.emitter('collection_added', jsonExpanded) res.json(jsonExpanded) @@ -313,29 +347,55 @@ class CollectionController { async addBatch(req, res) { // filter out invalid libraryItemIds const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string') - if (!bookIdsToAdd.length) { - return res.status(400).send('Invalid request body') + const seriesIdsToAdd = (req.body.seriesIds || []).filter((id) => !!id && typeof id === 'string') + const uniqueSeriesIds = [...new Set(seriesIdsToAdd)] + + if (!bookIdsToAdd.length && !uniqueSeriesIds.length) { + return res.status(400).send('Invalid request body. No books or series') } // Get library items associated with ids - const libraryItems = await Database.libraryItemModel.findAll({ - attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], - where: { - id: bookIdsToAdd, - libraryId: req.collection.libraryId, - mediaType: 'book' + let libraryItems = [] + if (bookIdsToAdd.length) { + libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], + where: { + id: bookIdsToAdd, + libraryId: req.collection.libraryId, + mediaType: 'book' + } + }) + if (!libraryItems.length && !uniqueSeriesIds.length) { + return res.status(400).send('Invalid request body. No valid books') } - }) - if (!libraryItems.length) { - return res.status(400).send('Invalid request body. No valid books') } - // Get collection books already in collection + // Validate series + if (uniqueSeriesIds.length) { + const seriesRecords = await Database.seriesModel.findAll({ + attributes: ['id'], + where: { + id: uniqueSeriesIds, + libraryId: req.collection.libraryId + } + }) + if (seriesRecords.length !== uniqueSeriesIds.length) { + return res.status(400).send('Invalid request body. Invalid series') + } + } + + // Get existing entries in collection /** @type {import('../models/CollectionBook')[]} */ const collectionBooks = await req.collection.getCollectionBooks() + const collectionSeriesItems = await req.collection.getCollectionSeriesItems() + + // Compute max order across both tables + const maxBookOrder = collectionBooks.length ? Math.max(...collectionBooks.map((cb) => cb.order)) : 0 + const maxSeriesOrder = collectionSeriesItems.length ? Math.max(...collectionSeriesItems.map((csi) => csi.order)) : 0 + let order = Math.max(maxBookOrder, maxSeriesOrder) + 1 - let order = collectionBooks.length + 1 const collectionBooksToAdd = [] + const collectionSeriesToAdd = [] let hasUpdated = false // Check and set new collection books to add @@ -352,14 +412,32 @@ class CollectionController { } } - let jsonExpanded = null - if (hasUpdated) { - await Database.collectionBookModel.bulkCreate(collectionBooksToAdd) + // Check and set new collection series to add + for (const seriesId of uniqueSeriesIds) { + if (!collectionSeriesItems.some((csi) => csi.seriesId === seriesId)) { + collectionSeriesToAdd.push({ + collectionId: req.collection.id, + seriesId, + order: order++ + }) + hasUpdated = true + } else { + Logger.warn(`[CollectionController] addBatch: Series ${seriesId} already in collection`) + } + } - jsonExpanded = await req.collection.getOldJsonExpanded() + if (hasUpdated) { + if (collectionBooksToAdd.length) { + await Database.collectionBookModel.bulkCreate(collectionBooksToAdd) + } + if (collectionSeriesToAdd.length) { + await Database.collectionSeriesItemModel.bulkCreate(collectionSeriesToAdd) + } + } + + const jsonExpanded = await req.collection.getOldJsonExpanded() + if (hasUpdated) { SocketAuthority.emitter('collection_updated', jsonExpanded) - } else { - jsonExpanded = await req.collection.getOldJsonExpanded() } res.json(jsonExpanded) } @@ -419,6 +497,52 @@ class CollectionController { res.json(jsonExpanded) } + /** + * DELETE: /api/collections/:id/series/:seriesId + * Remove a series entry from collection + * + * @param {CollectionControllerRequest} req + * @param {Response} res + */ + async removeSeries(req, res) { + const collectionSeriesItem = await Database.collectionSeriesItemModel.findOne({ + where: { + collectionId: req.collection.id, + seriesId: req.params.seriesId + } + }) + + if (!collectionSeriesItem) { + // Idempotent - return 200 if series not in collection + const jsonExpanded = await req.collection.getOldJsonExpanded() + return res.json(jsonExpanded) + } + + // Remove record + await collectionSeriesItem.destroy() + + // Re-compact orders across both tables + const collectionBooks = await req.collection.getCollectionBooks({ order: [['order', 'ASC']] }) + const remainingSeriesItems = await req.collection.getCollectionSeriesItems({ order: [['order', 'ASC']] }) + + // Merge and sort by current order + const allEntries = [ + ...collectionBooks.map((cb) => ({ record: cb, order: cb.order })), + ...remainingSeriesItems.map((csi) => ({ record: csi, order: csi.order })) + ].sort((a, b) => a.order - b.order) + + // Reassign sequential orders + for (let i = 0; i < allEntries.length; i++) { + if (allEntries[i].record.order !== i + 1) { + await allEntries[i].record.update({ order: i + 1 }) + } + } + + const jsonExpanded = await req.collection.getOldJsonExpanded() + SocketAuthority.emitter('collection_updated', jsonExpanded) + res.json(jsonExpanded) + } + /** * * @param {RequestWithUser} req @@ -434,8 +558,8 @@ class CollectionController { req.collection = collection } - // Users with update permission can remove books from collections - if (req.method == 'DELETE' && !req.params.bookId && !req.user.canDelete) { + // Users with update permission can remove books/series from collections + if (req.method == 'DELETE' && !req.params.bookId && !req.params.seriesId && !req.user.canDelete) { Logger.warn(`[CollectionController] User "${req.user.username}" attempted to delete without permission`) return res.sendStatus(403) } else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) { diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js index bc1a7a455..fbef2887f 100644 --- a/server/controllers/PlaylistController.js +++ b/server/controllers/PlaylistController.js @@ -38,7 +38,19 @@ class PlaylistController { return res.status(400).send('Invalid playlist description') } const items = reqBody.items || [] + const seriesIds = (reqBody.seriesIds || []).filter((id) => !!id && typeof id === 'string') const isPodcast = items.some((i) => i.episodeId) + + // Must have at least one item or series + if (!items.length && !seriesIds.length) { + return res.status(400).send('Invalid playlist data. No items or series') + } + + // Series entries not valid for podcast playlists + if (isPodcast && seriesIds.length) { + return res.status(400).send('Series entries are not valid for podcast playlists') + } + const libraryItemIds = new Set() for (const item of items) { if (!item.libraryItemId || typeof item.libraryItemId !== 'string') { @@ -53,16 +65,19 @@ class PlaylistController { } // 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' + let libraryItems = [] + if (libraryItemIds.size) { + 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') } - }) - if (libraryItems.length !== libraryItemIds.size) { - return res.status(400).send('Invalid playlist data. Invalid items') } // Validate podcast episodes @@ -79,6 +94,21 @@ class PlaylistController { } } + // Validate series exist in the same library + const uniqueSeriesIds = [...new Set(seriesIds)] + if (uniqueSeriesIds.length) { + const seriesRecords = await Database.seriesModel.findAll({ + attributes: ['id'], + where: { + id: uniqueSeriesIds, + libraryId: reqBody.libraryId + } + }) + if (seriesRecords.length !== uniqueSeriesIds.length) { + return res.status(400).send('Invalid playlist data. Invalid series') + } + } + const transaction = await Database.sequelize.transaction() try { // Create playlist @@ -92,15 +122,26 @@ class PlaylistController { { transaction } ) - // Create playlistMediaItems + // Create playlistMediaItems for books/episodes const playlistItemPayloads = [] - for (const [index, item] of items.entries()) { + let order = 1 + for (const item of items) { 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 + order: order++ + }) + } + + // Create playlistMediaItems for series + for (const seriesId of uniqueSeriesIds) { + playlistItemPayloads.push({ + playlistId: newPlaylist.id, + mediaItemId: seriesId, + mediaItemType: 'series', + order: order++ }) } @@ -367,8 +408,8 @@ class PlaylistController { const jsonExpanded = req.playlist.toOldJSONExpanded() - // Playlist is removed when there are no items - if (!jsonExpanded.items.length) { + // Playlist is removed when there are no entries (items + series) + if (!req.playlist.playlistMediaItems.length) { Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`) await req.playlist.destroy() SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded) @@ -387,26 +428,57 @@ class PlaylistController { * @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') + const items = req.body.items || [] + const seriesIds = (req.body.seriesIds || []).filter((id) => !!id && typeof id === 'string') + + // Validate items if provided + if (items.length) { + if (!Array.isArray(items) || items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) { + return res.status(400).send('Invalid request body items') + } + } + + if (!items.length && !seriesIds.length) { + return res.status(400).send('Invalid request body. No items or series') } // Find all library items - const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i)) + let libraryItems = [] + if (items.length) { + const libraryItemIds = new Set(items.map((i) => i.libraryItemId).filter((i) => i)) + libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) }) + if (libraryItems.length !== libraryItemIds.size) { + return res.status(400).send('Invalid request body items') + } + } - const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) }) - if (libraryItems.length !== libraryItemIds.size) { - return res.status(400).send('Invalid request body items') + // Validate series + const uniqueSeriesIds = [...new Set(seriesIds)] + if (uniqueSeriesIds.length) { + // Series not valid for podcast playlists + const isPodcast = items.some((i) => i.episodeId) + if (isPodcast) { + return res.status(400).send('Series entries are not valid for podcast playlists') + } + const seriesRecords = await Database.seriesModel.findAll({ + attributes: ['id'], + where: { + id: uniqueSeriesIds, + libraryId: req.playlist.libraryId + } + }) + if (seriesRecords.length !== uniqueSeriesIds.length) { + return res.status(400).send('Invalid request body. Invalid series') + } } 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) { + for (const item of items) { const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) const mediaItemId = item.episodeId || libraryItem.media.id @@ -420,28 +492,31 @@ class PlaylistController { 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.podcastEpisodes.find((ep) => ep.id === item.episodeId) - jsonExpanded.items.push({ - episodeId: item.episodeId, - episode: episode.toOldJSONExpanded(libraryItem.id), - libraryItemId: libraryItem.id, - libraryItem: libraryItem.toOldJSONMinified() - }) - } else { - jsonExpanded.items.push({ - libraryItemId: libraryItem.id, - libraryItem: libraryItem.toOldJSONExpanded() - }) - } } } + // Add series entries (skip duplicates) + for (const seriesId of uniqueSeriesIds) { + if (req.playlist.checkHasSeries(seriesId)) { + continue + } + mediaItemsToAdd.push({ + playlistId: req.playlist.id, + mediaItemId: seriesId, + mediaItemType: 'series', + order: order++ + }) + } + if (mediaItemsToAdd.length) { await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd) + } + // Reload fully to get consistent response with entries + req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem() + const jsonExpanded = req.playlist.toOldJSONExpanded() + + if (mediaItemsToAdd.length) { SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded) } @@ -557,6 +632,50 @@ class PlaylistController { } } + /** + * DELETE: /api/playlists/:id/series/:seriesId + * Remove a series entry from playlist + * + * @param {PlaylistControllerRequest} req + * @param {Response} res + */ + async removeSeries(req, res) { + req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem() + + const playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemType === 'series' && pmi.mediaItemId === req.params.seriesId) + if (!playlistMediaItem) { + // Idempotent - return 200 if series not in playlist + const jsonExpanded = req.playlist.toOldJSONExpanded() + return res.json(jsonExpanded) + } + + // 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 entries + if (!req.playlist.playlistMediaItems.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) + } + /** * * @param {RequestWithUser} req diff --git a/server/migrations/v2.34.0-add-series-to-playlists-collections.js b/server/migrations/v2.34.0-add-series-to-playlists-collections.js new file mode 100644 index 000000000..dcaaf5f6e --- /dev/null +++ b/server/migrations/v2.34.0-add-series-to-playlists-collections.js @@ -0,0 +1,107 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.34.0' +const migrationName = `${migrationVersion}-add-series-to-playlists-collections` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration creates the collectionSeriesItems table for storing + * series entries in collections. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('collectionSeriesItems')) { + logger.info(`${loggerPrefix} table "collectionSeriesItems" already exists`) + } else { + logger.info(`${loggerPrefix} creating table "collectionSeriesItems"`) + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + await queryInterface.createTable('collectionSeriesItems', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + order: { + type: DataTypes.INTEGER, + allowNull: false + }, + seriesId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'series' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + }, + collectionId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'collections' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + } + }) + logger.info(`${loggerPrefix} created table "collectionSeriesItems"`) + + // Index for querying series items by collection + await queryInterface.addIndex('collectionSeriesItems', { + name: 'collection_series_items_collectionId', + fields: ['collectionId'] + }) + logger.info(`${loggerPrefix} added index on collectionId`) + + // Unique constraint to prevent duplicate series in a collection + await queryInterface.addIndex('collectionSeriesItems', { + name: 'collection_series_items_unique', + fields: ['collectionId', 'seriesId'], + unique: true + }) + logger.info(`${loggerPrefix} added unique index on (collectionId, seriesId)`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script removes the collectionSeriesItems table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('collectionSeriesItems')) { + logger.info(`${loggerPrefix} dropping table "collectionSeriesItems"`) + await queryInterface.dropTable('collectionSeriesItems') + logger.info(`${loggerPrefix} dropped table "collectionSeriesItems"`) + } else { + logger.info(`${loggerPrefix} table "collectionSeriesItems" does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/Collection.js b/server/models/Collection.js index d5f36ba9c..e6ed751e8 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -21,6 +21,8 @@ class Collection extends Model { /** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */ this.books + /** @type {import('./CollectionSeriesItem')[]} - only set when expanded */ + this.collectionSeriesItems } /** @@ -70,6 +72,12 @@ class Collection extends Model { } ] }, + { + model: this.sequelize.models.collectionSeriesItem, + include: { + model: this.sequelize.models.series + } + }, ...collectionIncludes ], order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] @@ -92,11 +100,12 @@ class Collection extends Model { }) || [] // Users with restricted permissions will not see this collection - if (!books.length && c.books.length) { + // (only if collection has books but all are filtered out) + if (!books.length && c.books.length && !c.collectionSeriesItems?.length) { return null } - this.books = books + c.books = books const collectionExpanded = c.toOldJSONExpanded() @@ -137,6 +146,12 @@ class Collection extends Model { } } ] + }, + { + model: this.sequelize.models.collectionSeriesItem, + include: { + model: this.sequelize.models.series + } } ], order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] @@ -212,6 +227,20 @@ class Collection extends Model { }) } + /** + * Get series items in collection expanded with series data + * + * @returns {Promise} + */ + getSeriesItemsExpanded() { + return this.getCollectionSeriesItems({ + include: { + model: this.sequelize.models.series + }, + order: [['order', 'ASC']] + }) + } + /** * Get toOldJSONExpanded, items filtered for user permissions * @@ -221,6 +250,7 @@ class Collection extends Model { */ async getOldJsonExpanded(user, include) { this.books = await this.getBooksExpandedWithLibraryItem() + this.collectionSeriesItems = await this.getSeriesItemsExpanded() // Filter books using user permissions // TODO: Handle user permission restrictions on initial query @@ -236,7 +266,8 @@ class Collection extends Model { }) // Users with restricted permissions will not see this collection - if (!books.length && this.books.length) { + // (only if collection has books but all are filtered out and no series entries) + if (!books.length && this.books.length && !this.collectionSeriesItems?.length) { return null } @@ -278,6 +309,22 @@ class Collection extends Model { } const json = this.toOldJSON() + + // Build entries first (before books processing mutates book.libraryItem) + const bookEntries = this.books.map((book) => ({ + type: 'libraryItem', + libraryItemId: book.libraryItem?.id || null, + order: book.collectionBook?.order || 0 + })) + const seriesEntries = (this.collectionSeriesItems || []).map((csi) => ({ + type: 'series', + seriesId: csi.seriesId, + seriesName: csi.series?.name || null, + order: csi.order + })) + json.entries = [...bookEntries, ...seriesEntries].sort((a, b) => a.order - b.order) + + // books: backward-compatible array with only book entries (destructive to book.libraryItem) json.books = this.books.map((book) => { const libraryItem = book.libraryItem delete book.libraryItem diff --git a/server/models/CollectionSeriesItem.js b/server/models/CollectionSeriesItem.js new file mode 100644 index 000000000..0d73e9afe --- /dev/null +++ b/server/models/CollectionSeriesItem.js @@ -0,0 +1,54 @@ +const { DataTypes, Model } = require('sequelize') + +class CollectionSeriesItem extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {number} */ + this.order + /** @type {UUIDV4} */ + this.seriesId + /** @type {UUIDV4} */ + this.collectionId + /** @type {Date} */ + this.createdAt + } + + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + order: DataTypes.INTEGER + }, + { + sequelize, + timestamps: true, + updatedAt: false, + modelName: 'collectionSeriesItem' + } + ) + + // Super Many-to-Many + const { series, collection } = sequelize.models + series.belongsToMany(collection, { through: CollectionSeriesItem, as: 'seriesCollections' }) + collection.belongsToMany(series, { through: CollectionSeriesItem, as: 'collectionSeries' }) + + series.hasMany(CollectionSeriesItem, { + onDelete: 'CASCADE' + }) + CollectionSeriesItem.belongsTo(series) + + collection.hasMany(CollectionSeriesItem, { + onDelete: 'CASCADE' + }) + CollectionSeriesItem.belongsTo(collection) + } +} + +module.exports = CollectionSeriesItem diff --git a/server/models/Playlist.js b/server/models/Playlist.js index 35bd6c99e..e1c8205d9 100644 --- a/server/models/Playlist.js +++ b/server/models/Playlist.js @@ -75,6 +75,9 @@ class Playlist extends Model { model: this.sequelize.models.podcast, include: this.sequelize.models.libraryItem } + }, + { + model: this.sequelize.models.series } ] }, @@ -132,6 +135,9 @@ class Playlist extends Model { model: this.sequelize.models.podcast, include: this.sequelize.models.libraryItem } + }, + { + model: this.sequelize.models.series } ] } @@ -152,11 +158,16 @@ class Playlist extends Model { } else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) { pmi.mediaItem = pmi.podcastEpisode pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode + } else if (pmi.mediaItemType === 'series' && pmi.series !== undefined) { + pmi.mediaItem = pmi.series + pmi.dataValues.mediaItem = pmi.dataValues.series } delete pmi.book delete pmi.dataValues.book delete pmi.podcastEpisode delete pmi.dataValues.podcastEpisode + delete pmi.series + delete pmi.dataValues.series return pmi }) playlists.push(playlist) @@ -207,6 +218,64 @@ class Playlist extends Model { } } + /** + * Removes series entries from playlists and re-orders + * + * @param {string[]} seriesIds + */ + static async removeSeriesFromPlaylists(seriesIds) { + if (!seriesIds?.length) return + + const playlistMediaItems = await this.sequelize.models.playlistMediaItem.findAll({ + where: { + mediaItemType: 'series', + mediaItemId: { + [Op.in]: seriesIds + } + }, + include: { + model: this.sequelize.models.playlist + } + }) + + if (!playlistMediaItems.length) return + + // Group by playlist + const playlistMap = new Map() + for (const pmi of playlistMediaItems) { + if (!playlistMap.has(pmi.playlist.id)) { + playlistMap.set(pmi.playlist.id, { playlist: pmi.playlist, pmiIds: [] }) + } + playlistMap.get(pmi.playlist.id).pmiIds.push(pmi.id) + await pmi.destroy() + } + + for (const { playlist, pmiIds } of playlistMap.values()) { + // Re-order remaining items + const remainingPmis = await this.sequelize.models.playlistMediaItem.findAll({ + where: { playlistId: playlist.id }, + order: [['order', 'ASC']] + }) + + let order = 1 + for (const pmi of remainingPmis) { + if (pmi.order !== order) { + await pmi.update({ order }) + } + order++ + } + + const jsonExpanded = await playlist.getOldJsonExpanded() + if (!remainingPmis.length) { + Logger.info(`[Playlist] Playlist "${playlist.name}" has no more items - removing it`) + await playlist.destroy() + SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded) + } else { + SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) + } + } + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize @@ -251,12 +320,17 @@ class Playlist extends Model { } else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) { pmi.mediaItem = pmi.podcastEpisode pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode + } else if (pmi.mediaItemType === 'series' && pmi.series !== undefined) { + pmi.mediaItem = pmi.series + pmi.dataValues.mediaItem = pmi.dataValues.series } // To prevent mistakes: delete pmi.book delete pmi.dataValues.book delete pmi.podcastEpisode delete pmi.dataValues.podcastEpisode + delete pmi.series + delete pmi.dataValues.series return pmi }) } @@ -300,6 +374,9 @@ class Playlist extends Model { include: this.sequelize.models.libraryItem } ] + }, + { + model: this.sequelize.models.series } ], order: [['order', 'ASC']] @@ -329,7 +406,20 @@ class Playlist extends Model { if (episodeId) { return this.playlistMediaItems.some((pmi) => pmi.mediaItemId === episodeId) } - return this.playlistMediaItems.some((pmi) => pmi.mediaItem.libraryItem.id === libraryItemId) + return this.playlistMediaItems.some((pmi) => pmi.mediaItem?.libraryItem?.id === libraryItemId) + } + + /** + * Check if playlist has a series entry + * + * @param {string} seriesId + * @returns {boolean} + */ + checkHasSeries(seriesId) { + if (!this.playlistMediaItems) { + throw new Error('playlistMediaItems are required to check Playlist') + } + return this.playlistMediaItems.some((pmi) => pmi.mediaItemType === 'series' && pmi.mediaItemId === seriesId) } toOldJSON() { @@ -350,28 +440,58 @@ class Playlist extends Model { } 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 + + // Build entries first (before items processing mutates pmi.mediaItem) + json.entries = this.playlistMediaItems.map((pmi) => { + if (pmi.mediaItemType === 'series') { return { - libraryItemId: libraryItem.id, - libraryItem: libraryItem.toOldJSONExpanded() + type: 'series', + seriesId: pmi.mediaItemId, + seriesName: pmi.mediaItem.name, + order: pmi.order } } - const libraryItem = pmi.mediaItem.podcast.libraryItem - delete pmi.mediaItem.podcast.libraryItem - libraryItem.media = pmi.mediaItem.podcast + // libraryItem entry (book or podcastEpisode) + let libraryItemId = null + if (pmi.mediaItemType === 'book') { + libraryItemId = pmi.mediaItem?.libraryItem?.id || null + } else if (pmi.mediaItemType === 'podcastEpisode') { + libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || null + } return { - episodeId: pmi.mediaItemId, - episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id), - libraryItemId: libraryItem.id, - libraryItem: libraryItem.toOldJSONMinified() + type: 'libraryItem', + libraryItemId, + episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : null, + order: pmi.order } }) + // items: backward-compatible array with only book/episode entries (destructive to pmi.mediaItem) + json.items = this.playlistMediaItems + .filter((pmi) => pmi.mediaItemType !== 'series') + .map((pmi) => { + if (pmi.mediaItemType === 'book') { + const libraryItem = pmi.mediaItem.libraryItem + delete pmi.mediaItem.libraryItem + libraryItem.media = pmi.mediaItem + return { + libraryItemId: libraryItem.id, + libraryItem: libraryItem.toOldJSONExpanded() + } + } + + 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: libraryItem.toOldJSONMinified() + } + }) + return json } } diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js index 2eac036b7..10d4404ab 100644 --- a/server/models/PlaylistMediaItem.js +++ b/server/models/PlaylistMediaItem.js @@ -19,7 +19,7 @@ class PlaylistMediaItem extends Model { // Expanded properties - /** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */ + /** @type {import('./Book')|import('./PodcastEpisode')|import('./Series')} - only set when expanded */ this.mediaItem } @@ -53,7 +53,7 @@ class PlaylistMediaItem extends Model { } ) - const { book, podcastEpisode, playlist } = sequelize.models + const { book, podcastEpisode, playlist, series } = sequelize.models book.hasMany(PlaylistMediaItem, { foreignKey: 'mediaItemId', @@ -73,6 +73,15 @@ class PlaylistMediaItem extends Model { }) PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + series.hasMany(PlaylistMediaItem, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'series' + } + }) + PlaylistMediaItem.belongsTo(series, { foreignKey: 'mediaItemId', constraints: false }) + PlaylistMediaItem.addHook('afterFind', (findResult) => { if (!findResult) return @@ -85,12 +94,17 @@ class PlaylistMediaItem extends Model { } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { instance.mediaItem = instance.podcastEpisode instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + } else if (instance.mediaItemType === 'series' && instance.series !== undefined) { + instance.mediaItem = instance.series + instance.dataValues.mediaItem = instance.dataValues.series } // To prevent mistakes: delete instance.book delete instance.dataValues.book delete instance.podcastEpisode delete instance.dataValues.podcastEpisode + delete instance.series + delete instance.dataValues.series } }) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..187bf0ec1 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -152,6 +152,7 @@ class ApiRouter { this.router.delete('/collections/:id/book/:bookId', CollectionController.middleware.bind(this), CollectionController.removeBook.bind(this)) this.router.post('/collections/:id/batch/add', CollectionController.middleware.bind(this), CollectionController.addBatch.bind(this)) this.router.post('/collections/:id/batch/remove', CollectionController.middleware.bind(this), CollectionController.removeBatch.bind(this)) + this.router.delete('/collections/:id/series/:seriesId', CollectionController.middleware.bind(this), CollectionController.removeSeries.bind(this)) // // Playlist Routes @@ -165,6 +166,7 @@ class ApiRouter { this.router.delete('/playlists/:id/item/:libraryItemId/:episodeId?', PlaylistController.middleware.bind(this), PlaylistController.removeItem.bind(this)) this.router.post('/playlists/:id/batch/add', PlaylistController.middleware.bind(this), PlaylistController.addBatch.bind(this)) this.router.post('/playlists/:id/batch/remove', PlaylistController.middleware.bind(this), PlaylistController.removeBatch.bind(this)) + this.router.delete('/playlists/:id/series/:seriesId', PlaylistController.middleware.bind(this), PlaylistController.removeSeries.bind(this)) this.router.post('/playlists/collection/:collectionId', PlaylistController.createFromCollection.bind(this)) // @@ -445,9 +447,16 @@ class ApiRouter { Database.removeSeriesFromFilterData(libraryId, id) SocketAuthority.emitter('series_removed', { id: id, libraryId: libraryId }) }) - // Close rss feeds - remove from db and emit socket event if (seriesToRemove.length) { - await RssFeedManager.closeFeedsForEntityIds(seriesToRemove.map((s) => s.id)) + const removedSeriesIds = seriesToRemove.map((s) => s.id) + + // Clean up series entries from playlists (no FK constraint due to polymorphic pattern) + await Database.playlistModel.removeSeriesFromPlaylists(removedSeriesIds) + + // CollectionSeriesItem records are cleaned up by ON DELETE CASCADE + + // Close rss feeds - remove from db and emit socket event + await RssFeedManager.closeFeedsForEntityIds(removedSeriesIds) } } catch (error) { await transaction.rollback() diff --git a/test/server/controllers/CollectionController.seriesEntries.test.js b/test/server/controllers/CollectionController.seriesEntries.test.js new file mode 100644 index 000000000..824fecf44 --- /dev/null +++ b/test/server/controllers/CollectionController.seriesEntries.test.js @@ -0,0 +1,431 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const CollectionController = require('../../../server/controllers/CollectionController') +const Logger = require('../../../server/Logger') +const SocketAuthority = require('../../../server/SocketAuthority') + +describe('CollectionController - Series Entries', () => { + let user1, library, library2, series1, series2, book1, book2, libraryItem1, libraryItem2 + + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'error') + sinon.stub(Logger, 'warn') + sinon.stub(Logger, 'debug') + sinon.stub(SocketAuthority, 'emitter') + + // Create test data + library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + library2 = await Database.libraryModel.create({ name: 'Other Library', mediaType: 'book' }) + + user1 = await Database.userModel.create({ + username: 'user1', + pash: 'hashed_password_1', + type: 'user', + isActive: true, + permissions: JSON.stringify({ update: true, delete: true }) + }) + user1.mediaProgresses = [] + user1.canUpdate = true + user1.canDelete = true + user1.checkCanAccessLibraryItemWithTags = () => true + user1.canAccessExplicitContent = true + + series1 = await Database.seriesModel.create({ + name: 'The Expanse', + nameIgnorePrefix: 'Expanse', + libraryId: library.id + }) + + series2 = await Database.seriesModel.create({ + name: 'Foundation', + nameIgnorePrefix: 'Foundation', + libraryId: library.id + }) + + book1 = await Database.bookModel.create({ + title: 'Book One', + authorName: 'Author 1', + audioFiles: [], + chapters: [], + tags: [], + narrators: [], + genres: [] + }) + + libraryItem1 = await Database.libraryItemModel.create({ + path: '/books/book1', + relPath: 'book1', + mediaId: book1.id, + mediaType: 'book', + libraryId: library.id, + libraryFiles: [] + }) + + book2 = await Database.bookModel.create({ + title: 'Book Two', + authorName: 'Author 2', + audioFiles: [], + chapters: [], + tags: [], + narrators: [], + genres: [] + }) + + libraryItem2 = await Database.libraryItemModel.create({ + path: '/books/book2', + relPath: 'book2', + mediaId: book2.id, + mediaType: 'book', + libraryId: library.id, + libraryFiles: [] + }) + }) + + afterEach(async () => { + sinon.restore() + await Database.sequelize.sync({ force: true }) + }) + + function makeFakeRes() { + return { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy(), + json: sinon.spy() + } + } + + describe('create', () => { + it('should create collection with books and seriesIds', async () => { + const fakeReq = { + user: user1, + body: { + libraryId: library.id, + name: 'My Collection', + books: [libraryItem1.id], + seriesIds: [series1.id] + } + } + const fakeRes = makeFakeRes() + + await CollectionController.create(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + // books should only contain book entries + expect(response.books).to.have.length(1) + + // entries should contain both book and series + expect(response.entries).to.have.length(2) + expect(response.entries[0].type).to.equal('libraryItem') + expect(response.entries[0].libraryItemId).to.equal(libraryItem1.id) + expect(response.entries[0].order).to.equal(1) + expect(response.entries[1].type).to.equal('series') + expect(response.entries[1].seriesId).to.equal(series1.id) + expect(response.entries[1].seriesName).to.equal('The Expanse') + expect(response.entries[1].order).to.equal(2) + + // Socket event + expect(SocketAuthority.emitter.calledOnce).to.be.true + expect(SocketAuthority.emitter.firstCall.args[0]).to.equal('collection_added') + }) + + it('should create collection with only seriesIds', async () => { + const fakeReq = { + user: user1, + body: { + libraryId: library.id, + name: 'Series Only Collection', + seriesIds: [series1.id, series2.id] + } + } + const fakeRes = makeFakeRes() + + await CollectionController.create(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + expect(response.books).to.have.length(0) + expect(response.entries).to.have.length(2) + expect(response.entries[0].type).to.equal('series') + expect(response.entries[1].type).to.equal('series') + }) + + it('should reject invalid seriesId', async () => { + const fakeReq = { + user: user1, + body: { + libraryId: library.id, + name: 'Bad Collection', + seriesIds: ['00000000-0000-0000-0000-000000000000'] + } + } + const fakeRes = makeFakeRes() + + await CollectionController.create(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + expect(fakeRes.send.calledWith('Invalid collection data. Invalid series')).to.be.true + }) + + it('should reject collection with no books and no series', async () => { + const fakeReq = { + user: user1, + body: { + libraryId: library.id, + name: 'Empty' + } + } + const fakeRes = makeFakeRes() + + await CollectionController.create(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + }) + }) + + describe('addBatch', () => { + let collection + + beforeEach(async () => { + collection = await Database.collectionModel.create({ + libraryId: library.id, + name: 'Test Collection' + }) + await Database.collectionBookModel.create({ + collectionId: collection.id, + bookId: book1.id, + order: 1 + }) + }) + + it('should add series to existing collection', async () => { + const fakeReq = { + user: user1, + collection, + body: { + seriesIds: [series1.id] + } + } + const fakeRes = makeFakeRes() + + await CollectionController.addBatch(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + expect(response.entries).to.have.length(2) + expect(response.entries[0].type).to.equal('libraryItem') + expect(response.entries[0].order).to.equal(1) + expect(response.entries[1].type).to.equal('series') + expect(response.entries[1].seriesId).to.equal(series1.id) + expect(response.entries[1].order).to.equal(2) + }) + + it('should skip duplicate series', async () => { + // Add series first + await Database.collectionSeriesItemModel.create({ + collectionId: collection.id, + seriesId: series1.id, + order: 2 + }) + + const fakeReq = { + user: user1, + collection, + body: { + seriesIds: [series1.id] + } + } + const fakeRes = makeFakeRes() + + await CollectionController.addBatch(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + expect(response.entries).to.have.length(2) + expect(SocketAuthority.emitter.called).to.be.false + }) + + it('should reject invalid series', async () => { + const fakeReq = { + user: user1, + collection, + body: { + seriesIds: ['00000000-0000-0000-0000-000000000000'] + } + } + const fakeRes = makeFakeRes() + + await CollectionController.addBatch(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + }) + }) + + describe('removeSeries', () => { + let collection + + beforeEach(async () => { + collection = await Database.collectionModel.create({ + libraryId: library.id, + name: 'Test Collection' + }) + await Database.collectionBookModel.create({ + collectionId: collection.id, + bookId: book1.id, + order: 1 + }) + await Database.collectionSeriesItemModel.create({ + collectionId: collection.id, + seriesId: series1.id, + order: 2 + }) + await Database.collectionBookModel.create({ + collectionId: collection.id, + bookId: book2.id, + order: 3 + }) + }) + + it('should remove series and re-compact orders', async () => { + const fakeReq = { + user: user1, + collection, + params: { id: collection.id, seriesId: series1.id } + } + const fakeRes = makeFakeRes() + + await CollectionController.removeSeries(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + expect(response.entries).to.have.length(2) + expect(response.entries[0].order).to.equal(1) + expect(response.entries[1].order).to.equal(2) + expect(response.entries[0].type).to.equal('libraryItem') + expect(response.entries[1].type).to.equal('libraryItem') + + expect(SocketAuthority.emitter.calledOnce).to.be.true + expect(SocketAuthority.emitter.firstCall.args[0]).to.equal('collection_updated') + }) + + it('should return 200 for non-existent series (idempotent)', async () => { + const fakeReq = { + user: user1, + collection, + params: { id: collection.id, seriesId: '00000000-0000-0000-0000-000000000000' } + } + const fakeRes = makeFakeRes() + + await CollectionController.removeSeries(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + expect(response.entries).to.have.length(3) + }) + }) + + describe('findOne', () => { + it('should include entries in response', async () => { + const collection = await Database.collectionModel.create({ + libraryId: library.id, + name: 'Test Collection' + }) + await Database.collectionBookModel.create({ + collectionId: collection.id, + bookId: book1.id, + order: 1 + }) + await Database.collectionSeriesItemModel.create({ + collectionId: collection.id, + seriesId: series1.id, + order: 2 + }) + + const fakeReq = { + user: user1, + collection, + query: {} + } + const fakeRes = makeFakeRes() + + await CollectionController.findOne(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + expect(response.entries).to.be.an('array') + expect(response.entries).to.have.length(2) + expect(response.books).to.be.an('array') + expect(response.books).to.have.length(1) + }) + }) + + describe('backward compatibility', () => { + it('books array should only contain book entries', async () => { + const collection = await Database.collectionModel.create({ + libraryId: library.id, + name: 'Mixed Collection' + }) + await Database.collectionBookModel.create({ + collectionId: collection.id, + bookId: book1.id, + order: 1 + }) + await Database.collectionSeriesItemModel.create({ + collectionId: collection.id, + seriesId: series1.id, + order: 2 + }) + + collection.books = await collection.getBooksExpandedWithLibraryItem() + collection.collectionSeriesItems = await collection.getSeriesItemsExpanded() + + const json = collection.toOldJSONExpanded() + + // books should only have book entries + expect(json.books).to.have.length(1) + + // entries should have both + expect(json.entries).to.have.length(2) + expect(json.entries.filter((e) => e.type === 'libraryItem')).to.have.length(1) + expect(json.entries.filter((e) => e.type === 'series')).to.have.length(1) + }) + }) + + describe('cascade delete', () => { + it('should auto-remove CollectionSeriesItem when series is deleted', async () => { + const collection = await Database.collectionModel.create({ + libraryId: library.id, + name: 'Cascade Test' + }) + await Database.collectionSeriesItemModel.create({ + collectionId: collection.id, + seriesId: series1.id, + order: 1 + }) + + // Delete the series + await series1.destroy() + + // CollectionSeriesItem should be gone via CASCADE + const remaining = await Database.collectionSeriesItemModel.findAll({ + where: { collectionId: collection.id } + }) + expect(remaining).to.have.length(0) + }) + }) +}) diff --git a/test/server/controllers/PlaylistController.seriesEntries.test.js b/test/server/controllers/PlaylistController.seriesEntries.test.js new file mode 100644 index 000000000..542e31d3c --- /dev/null +++ b/test/server/controllers/PlaylistController.seriesEntries.test.js @@ -0,0 +1,585 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const PlaylistController = require('../../../server/controllers/PlaylistController') +const Logger = require('../../../server/Logger') +const SocketAuthority = require('../../../server/SocketAuthority') + +describe('PlaylistController - Series Entries', () => { + let user1, library, library2, series1, series2, book1, book2, libraryItem1, libraryItem2 + + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'error') + sinon.stub(Logger, 'warn') + sinon.stub(Logger, 'debug') + sinon.stub(SocketAuthority, 'clientEmitter') + + // Create test data + library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + library2 = await Database.libraryModel.create({ name: 'Other Library', mediaType: 'book' }) + + user1 = await Database.userModel.create({ + username: 'user1', + pash: 'hashed_password_1', + type: 'user', + isActive: true + }) + user1.mediaProgresses = [] + + series1 = await Database.seriesModel.create({ + name: 'The Expanse', + nameIgnorePrefix: 'Expanse', + libraryId: library.id + }) + + series2 = await Database.seriesModel.create({ + name: 'Foundation', + nameIgnorePrefix: 'Foundation', + libraryId: library.id + }) + + book1 = await Database.bookModel.create({ + title: 'Book One', + authorName: 'Author 1', + audioFiles: [], + chapters: [], + tags: [], + narrators: [], + genres: [] + }) + + libraryItem1 = await Database.libraryItemModel.create({ + path: '/books/book1', + relPath: 'book1', + mediaId: book1.id, + mediaType: 'book', + libraryId: library.id, + libraryFiles: [] + }) + + book2 = await Database.bookModel.create({ + title: 'Book Two', + authorName: 'Author 2', + audioFiles: [], + chapters: [], + tags: [], + narrators: [], + genres: [] + }) + + libraryItem2 = await Database.libraryItemModel.create({ + path: '/books/book2', + relPath: 'book2', + mediaId: book2.id, + mediaType: 'book', + libraryId: library.id, + libraryFiles: [] + }) + }) + + afterEach(async () => { + sinon.restore() + await Database.sequelize.sync({ force: true }) + }) + + function makeFakeRes() { + return { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy(), + json: sinon.spy() + } + } + + describe('create', () => { + it('should create playlist with items and seriesIds', async () => { + const fakeReq = { + user: user1, + body: { + libraryId: library.id, + name: 'My Playlist', + items: [{ libraryItemId: libraryItem1.id }], + seriesIds: [series1.id] + } + } + const fakeRes = makeFakeRes() + + await PlaylistController.create(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + // items should only contain book entries + expect(response.items).to.have.length(1) + expect(response.items[0].libraryItemId).to.equal(libraryItem1.id) + + // entries should contain both book and series + expect(response.entries).to.have.length(2) + expect(response.entries[0].type).to.equal('libraryItem') + expect(response.entries[0].libraryItemId).to.equal(libraryItem1.id) + expect(response.entries[0].order).to.equal(1) + expect(response.entries[1].type).to.equal('series') + expect(response.entries[1].seriesId).to.equal(series1.id) + expect(response.entries[1].seriesName).to.equal('The Expanse') + expect(response.entries[1].order).to.equal(2) + + // Socket event + expect(SocketAuthority.clientEmitter.calledOnce).to.be.true + const [userId, event] = SocketAuthority.clientEmitter.firstCall.args + expect(userId).to.equal(user1.id) + expect(event).to.equal('playlist_added') + }) + + it('should create playlist with only seriesIds (no items)', async () => { + const fakeReq = { + user: user1, + body: { + libraryId: library.id, + name: 'Series Only Playlist', + seriesIds: [series1.id, series2.id] + } + } + const fakeRes = makeFakeRes() + + await PlaylistController.create(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + // items should be empty (no book/episode entries) + expect(response.items).to.have.length(0) + + // entries should contain both series + expect(response.entries).to.have.length(2) + expect(response.entries[0].type).to.equal('series') + expect(response.entries[0].seriesId).to.equal(series1.id) + expect(response.entries[1].type).to.equal('series') + expect(response.entries[1].seriesId).to.equal(series2.id) + }) + + it('should reject seriesIds for podcast playlists', async () => { + const podcast = await Database.podcastModel.create({ title: 'Test Podcast' }) + const podcastLI = await Database.libraryItemModel.create({ + path: '/podcasts/p1', + relPath: 'p1', + mediaId: podcast.id, + mediaType: 'podcast', + libraryId: library.id + }) + const episode = await Database.podcastEpisodeModel.create({ + title: 'Episode 1', + podcastId: podcast.id + }) + + const fakeReq = { + user: user1, + body: { + libraryId: library.id, + name: 'Podcast Playlist', + items: [{ libraryItemId: podcastLI.id, episodeId: episode.id }], + seriesIds: [series1.id] + } + } + const fakeRes = makeFakeRes() + + await PlaylistController.create(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + expect(fakeRes.send.calledWith('Series entries are not valid for podcast playlists')).to.be.true + }) + + it('should reject invalid seriesId', async () => { + const fakeReq = { + user: user1, + body: { + libraryId: library.id, + name: 'Bad Playlist', + seriesIds: ['00000000-0000-0000-0000-000000000000'] + } + } + const fakeRes = makeFakeRes() + + await PlaylistController.create(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + expect(fakeRes.send.calledWith('Invalid playlist data. Invalid series')).to.be.true + }) + + it('should reject seriesId from different library', async () => { + const otherSeries = await Database.seriesModel.create({ + name: 'Other Series', + nameIgnorePrefix: 'Other Series', + libraryId: library2.id + }) + + const fakeReq = { + user: user1, + body: { + libraryId: library.id, + name: 'Cross Library', + seriesIds: [otherSeries.id] + } + } + const fakeRes = makeFakeRes() + + await PlaylistController.create(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + }) + + it('should reject playlist with no items and no seriesIds', async () => { + const fakeReq = { + user: user1, + body: { + libraryId: library.id, + name: 'Empty Playlist' + } + } + const fakeRes = makeFakeRes() + + await PlaylistController.create(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + }) + }) + + describe('addBatch', () => { + let playlist + + beforeEach(async () => { + // Create a playlist with one book + playlist = await Database.playlistModel.create({ + libraryId: library.id, + userId: user1.id, + name: 'Test Playlist' + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: book1.id, + mediaItemType: 'book', + order: 1 + }) + }) + + it('should add series to existing playlist', async () => { + const fakeReq = { + user: user1, + playlist, + body: { + seriesIds: [series1.id] + } + } + const fakeRes = makeFakeRes() + + await PlaylistController.addBatch(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + expect(response.entries).to.have.length(2) + expect(response.entries[0].type).to.equal('libraryItem') + expect(response.entries[0].order).to.equal(1) + expect(response.entries[1].type).to.equal('series') + expect(response.entries[1].seriesId).to.equal(series1.id) + expect(response.entries[1].order).to.equal(2) + }) + + it('should skip duplicate series', async () => { + // Add series first + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: series1.id, + mediaItemType: 'series', + order: 2 + }) + + const fakeReq = { + user: user1, + playlist, + body: { + seriesIds: [series1.id] + } + } + const fakeRes = makeFakeRes() + + await PlaylistController.addBatch(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + // Should still have only 2 entries (no duplicate) + expect(response.entries).to.have.length(2) + expect(SocketAuthority.clientEmitter.called).to.be.false + }) + + it('should add only series (no items in request)', async () => { + const fakeReq = { + user: user1, + playlist, + body: { + seriesIds: [series1.id, series2.id] + } + } + const fakeRes = makeFakeRes() + + await PlaylistController.addBatch(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + expect(response.entries).to.have.length(3) + expect(response.entries[1].type).to.equal('series') + expect(response.entries[2].type).to.equal('series') + }) + }) + + describe('removeSeries', () => { + let playlist + + beforeEach(async () => { + playlist = await Database.playlistModel.create({ + libraryId: library.id, + userId: user1.id, + name: 'Test Playlist' + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: book1.id, + mediaItemType: 'book', + order: 1 + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: series1.id, + mediaItemType: 'series', + order: 2 + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: book2.id, + mediaItemType: 'book', + order: 3 + }) + }) + + it('should remove series and re-compact orders', async () => { + const fakeReq = { + user: user1, + playlist, + params: { id: playlist.id, seriesId: series1.id } + } + const fakeRes = makeFakeRes() + + await PlaylistController.removeSeries(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + // Should have 2 entries left (both books) + expect(response.entries).to.have.length(2) + expect(response.entries[0].order).to.equal(1) + expect(response.entries[1].order).to.equal(2) + + // items should still have both books + expect(response.items).to.have.length(2) + + expect(SocketAuthority.clientEmitter.calledOnce).to.be.true + expect(SocketAuthority.clientEmitter.firstCall.args[1]).to.equal('playlist_updated') + }) + + it('should return 200 for non-existent series (idempotent)', async () => { + const fakeReq = { + user: user1, + playlist, + params: { id: playlist.id, seriesId: '00000000-0000-0000-0000-000000000000' } + } + const fakeRes = makeFakeRes() + + await PlaylistController.removeSeries(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + // All 3 entries should remain + expect(response.entries).to.have.length(3) + }) + + it('should delete playlist when last entry is removed', async () => { + // Create a playlist with only one series entry + const soloPlaylist = await Database.playlistModel.create({ + libraryId: library.id, + userId: user1.id, + name: 'Solo Series Playlist' + }) + await Database.playlistMediaItemModel.create({ + playlistId: soloPlaylist.id, + mediaItemId: series1.id, + mediaItemType: 'series', + order: 1 + }) + + const fakeReq = { + user: user1, + playlist: soloPlaylist, + params: { id: soloPlaylist.id, seriesId: series1.id } + } + const fakeRes = makeFakeRes() + + await PlaylistController.removeSeries(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + expect(SocketAuthority.clientEmitter.calledOnce).to.be.true + expect(SocketAuthority.clientEmitter.firstCall.args[1]).to.equal('playlist_removed') + + // Playlist should be deleted from DB + const deletedPlaylist = await Database.playlistModel.findByPk(soloPlaylist.id) + expect(deletedPlaylist).to.be.null + }) + }) + + describe('findOne', () => { + it('should include entries in response', async () => { + const playlist = await Database.playlistModel.create({ + libraryId: library.id, + userId: user1.id, + name: 'Test Playlist' + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: book1.id, + mediaItemType: 'book', + order: 1 + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: series1.id, + mediaItemType: 'series', + order: 2 + }) + + const fakeReq = { + user: user1, + playlist, + params: { id: playlist.id } + } + const fakeRes = makeFakeRes() + + await PlaylistController.findOne(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const response = fakeRes.json.firstCall.args[0] + + expect(response.entries).to.be.an('array') + expect(response.entries).to.have.length(2) + expect(response.items).to.be.an('array') + expect(response.items).to.have.length(1) + }) + }) + + describe('backward compatibility', () => { + it('items array should only contain book/episode entries', async () => { + const playlist = await Database.playlistModel.create({ + libraryId: library.id, + userId: user1.id, + name: 'Mixed Playlist' + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: book1.id, + mediaItemType: 'book', + order: 1 + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: series1.id, + mediaItemType: 'series', + order: 2 + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: book2.id, + mediaItemType: 'book', + order: 3 + }) + + playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem() + const json = playlist.toOldJSONExpanded() + + // items should only have books, not series + expect(json.items).to.have.length(2) + json.items.forEach((item) => { + expect(item.libraryItemId).to.be.a('string') + }) + + // entries should have all three + expect(json.entries).to.have.length(3) + expect(json.entries.filter((e) => e.type === 'libraryItem')).to.have.length(2) + expect(json.entries.filter((e) => e.type === 'series')).to.have.length(1) + }) + }) + + describe('removeSeriesFromPlaylists', () => { + it('should clean up playlist when series is removed', async () => { + const playlist = await Database.playlistModel.create({ + libraryId: library.id, + userId: user1.id, + name: 'Cleanup Test' + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: book1.id, + mediaItemType: 'book', + order: 1 + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: series1.id, + mediaItemType: 'series', + order: 2 + }) + + await Database.playlistModel.removeSeriesFromPlaylists([series1.id]) + + // Verify series PMI was removed + const remainingPmis = await Database.playlistMediaItemModel.findAll({ + where: { playlistId: playlist.id } + }) + expect(remainingPmis).to.have.length(1) + expect(remainingPmis[0].mediaItemType).to.equal('book') + expect(remainingPmis[0].order).to.equal(1) + + // Socket should emit update + expect(SocketAuthority.clientEmitter.calledOnce).to.be.true + expect(SocketAuthority.clientEmitter.firstCall.args[1]).to.equal('playlist_updated') + }) + + it('should delete playlist when series removal empties it', async () => { + const playlist = await Database.playlistModel.create({ + libraryId: library.id, + userId: user1.id, + name: 'Series Only' + }) + await Database.playlistMediaItemModel.create({ + playlistId: playlist.id, + mediaItemId: series1.id, + mediaItemType: 'series', + order: 1 + }) + + await Database.playlistModel.removeSeriesFromPlaylists([series1.id]) + + const deletedPlaylist = await Database.playlistModel.findByPk(playlist.id) + expect(deletedPlaylist).to.be.null + + expect(SocketAuthority.clientEmitter.calledOnce).to.be.true + expect(SocketAuthority.clientEmitter.firstCall.args[1]).to.equal('playlist_removed') + }) + }) +})