Support series in playlists and collections

This commit is contained in:
Paul DeVito 2026-03-24 22:32:43 -04:00
parent 8b89b27654
commit 84e6006708
11 changed files with 1750 additions and 104 deletions

View file

@ -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: {

View file

@ -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) {

View file

@ -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

View file

@ -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<void>} - 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<void>} - 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 }

View file

@ -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<import('./CollectionSeriesItem')[]>}
*/
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

View file

@ -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

View file

@ -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
}
}

View file

@ -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
}
})

View file

@ -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()

View file

@ -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)
})
})
})

View file

@ -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')
})
})
})