mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
Support series in playlists and collections
This commit is contained in:
parent
8b89b27654
commit
84e6006708
11 changed files with 1750 additions and 104 deletions
|
|
@ -162,6 +162,11 @@ class Database {
|
||||||
return this.models.device
|
return this.models.device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/CollectionSeriesItem')} */
|
||||||
|
get collectionSeriesItemModel() {
|
||||||
|
return this.models.collectionSeriesItem
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if db file exists
|
* Check if db file exists
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
|
|
@ -336,6 +341,7 @@ class Database {
|
||||||
require('./models/BookAuthor').init(this.sequelize)
|
require('./models/BookAuthor').init(this.sequelize)
|
||||||
require('./models/Collection').init(this.sequelize)
|
require('./models/Collection').init(this.sequelize)
|
||||||
require('./models/CollectionBook').init(this.sequelize)
|
require('./models/CollectionBook').init(this.sequelize)
|
||||||
|
require('./models/CollectionSeriesItem').init(this.sequelize)
|
||||||
require('./models/Playlist').init(this.sequelize)
|
require('./models/Playlist').init(this.sequelize)
|
||||||
require('./models/PlaylistMediaItem').init(this.sequelize)
|
require('./models/PlaylistMediaItem').init(this.sequelize)
|
||||||
require('./models/Device').init(this.sequelize)
|
require('./models/Device').init(this.sequelize)
|
||||||
|
|
@ -721,7 +727,7 @@ class Database {
|
||||||
await libraryItem.destroy()
|
await libraryItem.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove invalid PlaylistMediaItem records
|
// Remove invalid PlaylistMediaItem records (book/podcastEpisode types)
|
||||||
const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({
|
const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
|
|
@ -734,6 +740,7 @@ class Database {
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
where: {
|
where: {
|
||||||
|
mediaItemType: ['book', 'podcastEpisode'],
|
||||||
'$book.id$': null,
|
'$book.id$': null,
|
||||||
'$podcastEpisode.id$': null
|
'$podcastEpisode.id$': null
|
||||||
}
|
}
|
||||||
|
|
@ -743,6 +750,22 @@ class Database {
|
||||||
await playlistMediaItem.destroy()
|
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
|
// Remove invalid CollectionBook records
|
||||||
const collectionBooksWithNoBook = await this.collectionBookModel.findAll({
|
const collectionBooksWithNoBook = await this.collectionBookModel.findAll({
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -756,6 +779,19 @@ class Database {
|
||||||
await collectionBook.destroy()
|
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
|
// Remove empty series
|
||||||
const emptySeries = await this.seriesModel.findAll({
|
const emptySeries = await this.seriesModel.findAll({
|
||||||
include: {
|
include: {
|
||||||
|
|
|
||||||
|
|
@ -42,21 +42,42 @@ class CollectionController {
|
||||||
return res.status(400).send('Invalid collection description')
|
return res.status(400).send('Invalid collection description')
|
||||||
}
|
}
|
||||||
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
|
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
|
||||||
if (!libraryItemIds.length) {
|
const seriesIds = (reqBody.seriesIds || []).filter((id) => !!id && typeof id === 'string')
|
||||||
return res.status(400).send('Invalid collection data. No books')
|
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
|
// Load and validate library items
|
||||||
const libraryItems = await Database.libraryItemModel.findAll({
|
let libraryItems = []
|
||||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
if (libraryItemIds.length) {
|
||||||
where: {
|
libraryItems = await Database.libraryItemModel.findAll({
|
||||||
id: libraryItemIds,
|
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||||
libraryId: reqBody.libraryId,
|
where: {
|
||||||
mediaType: 'book'
|
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')} */
|
/** @type {import('../models/Collection')} */
|
||||||
|
|
@ -75,15 +96,28 @@ class CollectionController {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create collectionBooks
|
// Create collectionBooks
|
||||||
const collectionBookPayloads = libraryItemIds.map((llid, index) => {
|
let order = 1
|
||||||
const libraryItem = libraryItems.find((li) => li.id === llid)
|
if (libraryItemIds.length) {
|
||||||
return {
|
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,
|
collectionId: newCollection.id,
|
||||||
bookId: libraryItem.mediaId,
|
seriesId,
|
||||||
order: index + 1
|
order: order++
|
||||||
}
|
}))
|
||||||
})
|
await Database.collectionSeriesItemModel.bulkCreate(collectionSeriesPayloads, { transaction })
|
||||||
await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })
|
}
|
||||||
|
|
||||||
await transaction.commit()
|
await transaction.commit()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -92,10 +126,10 @@ class CollectionController {
|
||||||
return res.status(500).send('Failed to create collection')
|
return res.status(500).send('Failed to create collection')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load books expanded
|
// Load books and series expanded
|
||||||
newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()
|
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()
|
const jsonExpanded = newCollection.toOldJSONExpanded()
|
||||||
SocketAuthority.emitter('collection_added', jsonExpanded)
|
SocketAuthority.emitter('collection_added', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
|
|
@ -313,29 +347,55 @@ class CollectionController {
|
||||||
async addBatch(req, res) {
|
async addBatch(req, res) {
|
||||||
// filter out invalid libraryItemIds
|
// filter out invalid libraryItemIds
|
||||||
const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
|
const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
|
||||||
if (!bookIdsToAdd.length) {
|
const seriesIdsToAdd = (req.body.seriesIds || []).filter((id) => !!id && typeof id === 'string')
|
||||||
return res.status(400).send('Invalid request body')
|
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
|
// Get library items associated with ids
|
||||||
const libraryItems = await Database.libraryItemModel.findAll({
|
let libraryItems = []
|
||||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
if (bookIdsToAdd.length) {
|
||||||
where: {
|
libraryItems = await Database.libraryItemModel.findAll({
|
||||||
id: bookIdsToAdd,
|
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||||
libraryId: req.collection.libraryId,
|
where: {
|
||||||
mediaType: 'book'
|
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')[]} */
|
/** @type {import('../models/CollectionBook')[]} */
|
||||||
const collectionBooks = await req.collection.getCollectionBooks()
|
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 collectionBooksToAdd = []
|
||||||
|
const collectionSeriesToAdd = []
|
||||||
let hasUpdated = false
|
let hasUpdated = false
|
||||||
|
|
||||||
// Check and set new collection books to add
|
// Check and set new collection books to add
|
||||||
|
|
@ -352,14 +412,32 @@ class CollectionController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let jsonExpanded = null
|
// Check and set new collection series to add
|
||||||
if (hasUpdated) {
|
for (const seriesId of uniqueSeriesIds) {
|
||||||
await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
|
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)
|
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||||
} else {
|
|
||||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
|
||||||
}
|
}
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
@ -419,6 +497,52 @@ class CollectionController {
|
||||||
res.json(jsonExpanded)
|
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
|
* @param {RequestWithUser} req
|
||||||
|
|
@ -434,8 +558,8 @@ class CollectionController {
|
||||||
req.collection = collection
|
req.collection = collection
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users with update permission can remove books from collections
|
// Users with update permission can remove books/series from collections
|
||||||
if (req.method == 'DELETE' && !req.params.bookId && !req.user.canDelete) {
|
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`)
|
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to delete without permission`)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
|
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,19 @@ class PlaylistController {
|
||||||
return res.status(400).send('Invalid playlist description')
|
return res.status(400).send('Invalid playlist description')
|
||||||
}
|
}
|
||||||
const items = reqBody.items || []
|
const items = reqBody.items || []
|
||||||
|
const seriesIds = (reqBody.seriesIds || []).filter((id) => !!id && typeof id === 'string')
|
||||||
const isPodcast = items.some((i) => i.episodeId)
|
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()
|
const libraryItemIds = new Set()
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
|
if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
|
||||||
|
|
@ -53,16 +65,19 @@ class PlaylistController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load library items
|
// Load library items
|
||||||
const libraryItems = await Database.libraryItemModel.findAll({
|
let libraryItems = []
|
||||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
if (libraryItemIds.size) {
|
||||||
where: {
|
libraryItems = await Database.libraryItemModel.findAll({
|
||||||
id: Array.from(libraryItemIds),
|
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||||
libraryId: reqBody.libraryId,
|
where: {
|
||||||
mediaType: isPodcast ? 'podcast' : 'book'
|
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
|
// 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()
|
const transaction = await Database.sequelize.transaction()
|
||||||
try {
|
try {
|
||||||
// Create playlist
|
// Create playlist
|
||||||
|
|
@ -92,15 +122,26 @@ class PlaylistController {
|
||||||
{ transaction }
|
{ transaction }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create playlistMediaItems
|
// Create playlistMediaItems for books/episodes
|
||||||
const playlistItemPayloads = []
|
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)
|
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||||
playlistItemPayloads.push({
|
playlistItemPayloads.push({
|
||||||
playlistId: newPlaylist.id,
|
playlistId: newPlaylist.id,
|
||||||
mediaItemId: item.episodeId || libraryItem.mediaId,
|
mediaItemId: item.episodeId || libraryItem.mediaId,
|
||||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
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()
|
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||||
|
|
||||||
// Playlist is removed when there are no items
|
// Playlist is removed when there are no entries (items + series)
|
||||||
if (!jsonExpanded.items.length) {
|
if (!req.playlist.playlistMediaItems.length) {
|
||||||
Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
|
Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
|
||||||
await req.playlist.destroy()
|
await req.playlist.destroy()
|
||||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||||
|
|
@ -387,26 +428,57 @@ class PlaylistController {
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async addBatch(req, 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'))) {
|
const items = req.body.items || []
|
||||||
return res.status(400).send('Invalid request 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
|
// 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) })
|
// Validate series
|
||||||
if (libraryItems.length !== libraryItemIds.size) {
|
const uniqueSeriesIds = [...new Set(seriesIds)]
|
||||||
return res.status(400).send('Invalid request body items')
|
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()
|
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||||
|
|
||||||
const mediaItemsToAdd = []
|
const mediaItemsToAdd = []
|
||||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
|
||||||
|
|
||||||
// Setup array of playlistMediaItem records to add
|
// Setup array of playlistMediaItem records to add
|
||||||
let order = req.playlist.playlistMediaItems.length + 1
|
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 libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||||
|
|
||||||
const mediaItemId = item.episodeId || libraryItem.media.id
|
const mediaItemId = item.episodeId || libraryItem.media.id
|
||||||
|
|
@ -420,28 +492,31 @@ class PlaylistController {
|
||||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||||
order: order++
|
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) {
|
if (mediaItemsToAdd.length) {
|
||||||
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
|
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)
|
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
|
* @param {RequestWithUser} req
|
||||||
|
|
|
||||||
107
server/migrations/v2.34.0-add-series-to-playlists-collections.js
Normal file
107
server/migrations/v2.34.0-add-series-to-playlists-collections.js
Normal 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 }
|
||||||
|
|
@ -21,6 +21,8 @@ class Collection extends Model {
|
||||||
|
|
||||||
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
|
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
|
||||||
this.books
|
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
|
...collectionIncludes
|
||||||
],
|
],
|
||||||
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
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
|
// 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
this.books = books
|
c.books = books
|
||||||
|
|
||||||
const collectionExpanded = c.toOldJSONExpanded()
|
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']]
|
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
|
* Get toOldJSONExpanded, items filtered for user permissions
|
||||||
*
|
*
|
||||||
|
|
@ -221,6 +250,7 @@ class Collection extends Model {
|
||||||
*/
|
*/
|
||||||
async getOldJsonExpanded(user, include) {
|
async getOldJsonExpanded(user, include) {
|
||||||
this.books = await this.getBooksExpandedWithLibraryItem()
|
this.books = await this.getBooksExpandedWithLibraryItem()
|
||||||
|
this.collectionSeriesItems = await this.getSeriesItemsExpanded()
|
||||||
|
|
||||||
// Filter books using user permissions
|
// Filter books using user permissions
|
||||||
// TODO: Handle user permission restrictions on initial query
|
// 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
|
// 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,6 +309,22 @@ class Collection extends Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = this.toOldJSON()
|
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) => {
|
json.books = this.books.map((book) => {
|
||||||
const libraryItem = book.libraryItem
|
const libraryItem = book.libraryItem
|
||||||
delete book.libraryItem
|
delete book.libraryItem
|
||||||
|
|
|
||||||
54
server/models/CollectionSeriesItem.js
Normal file
54
server/models/CollectionSeriesItem.js
Normal 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
|
||||||
|
|
@ -75,6 +75,9 @@ class Playlist extends Model {
|
||||||
model: this.sequelize.models.podcast,
|
model: this.sequelize.models.podcast,
|
||||||
include: this.sequelize.models.libraryItem
|
include: this.sequelize.models.libraryItem
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.series
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -132,6 +135,9 @@ class Playlist extends Model {
|
||||||
model: this.sequelize.models.podcast,
|
model: this.sequelize.models.podcast,
|
||||||
include: this.sequelize.models.libraryItem
|
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) {
|
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
|
||||||
pmi.mediaItem = pmi.podcastEpisode
|
pmi.mediaItem = pmi.podcastEpisode
|
||||||
pmi.dataValues.mediaItem = pmi.dataValues.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.book
|
||||||
delete pmi.dataValues.book
|
delete pmi.dataValues.book
|
||||||
delete pmi.podcastEpisode
|
delete pmi.podcastEpisode
|
||||||
delete pmi.dataValues.podcastEpisode
|
delete pmi.dataValues.podcastEpisode
|
||||||
|
delete pmi.series
|
||||||
|
delete pmi.dataValues.series
|
||||||
return pmi
|
return pmi
|
||||||
})
|
})
|
||||||
playlists.push(playlist)
|
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
|
* Initialize model
|
||||||
* @param {import('../Database').sequelize} sequelize
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
|
@ -251,12 +320,17 @@ class Playlist extends Model {
|
||||||
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
|
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
|
||||||
pmi.mediaItem = pmi.podcastEpisode
|
pmi.mediaItem = pmi.podcastEpisode
|
||||||
pmi.dataValues.mediaItem = pmi.dataValues.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:
|
// To prevent mistakes:
|
||||||
delete pmi.book
|
delete pmi.book
|
||||||
delete pmi.dataValues.book
|
delete pmi.dataValues.book
|
||||||
delete pmi.podcastEpisode
|
delete pmi.podcastEpisode
|
||||||
delete pmi.dataValues.podcastEpisode
|
delete pmi.dataValues.podcastEpisode
|
||||||
|
delete pmi.series
|
||||||
|
delete pmi.dataValues.series
|
||||||
return pmi
|
return pmi
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -300,6 +374,9 @@ class Playlist extends Model {
|
||||||
include: this.sequelize.models.libraryItem
|
include: this.sequelize.models.libraryItem
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.series
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['order', 'ASC']]
|
order: [['order', 'ASC']]
|
||||||
|
|
@ -329,7 +406,20 @@ class Playlist extends Model {
|
||||||
if (episodeId) {
|
if (episodeId) {
|
||||||
return this.playlistMediaItems.some((pmi) => pmi.mediaItemId === 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() {
|
toOldJSON() {
|
||||||
|
|
@ -350,28 +440,58 @@ class Playlist extends Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = this.toOldJSON()
|
const json = this.toOldJSON()
|
||||||
json.items = this.playlistMediaItems.map((pmi) => {
|
|
||||||
if (pmi.mediaItemType === 'book') {
|
// Build entries first (before items processing mutates pmi.mediaItem)
|
||||||
const libraryItem = pmi.mediaItem.libraryItem
|
json.entries = this.playlistMediaItems.map((pmi) => {
|
||||||
delete pmi.mediaItem.libraryItem
|
if (pmi.mediaItemType === 'series') {
|
||||||
libraryItem.media = pmi.mediaItem
|
|
||||||
return {
|
return {
|
||||||
libraryItemId: libraryItem.id,
|
type: 'series',
|
||||||
libraryItem: libraryItem.toOldJSONExpanded()
|
seriesId: pmi.mediaItemId,
|
||||||
|
seriesName: pmi.mediaItem.name,
|
||||||
|
order: pmi.order
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItem = pmi.mediaItem.podcast.libraryItem
|
// libraryItem entry (book or podcastEpisode)
|
||||||
delete pmi.mediaItem.podcast.libraryItem
|
let libraryItemId = null
|
||||||
libraryItem.media = pmi.mediaItem.podcast
|
if (pmi.mediaItemType === 'book') {
|
||||||
|
libraryItemId = pmi.mediaItem?.libraryItem?.id || null
|
||||||
|
} else if (pmi.mediaItemType === 'podcastEpisode') {
|
||||||
|
libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || null
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
episodeId: pmi.mediaItemId,
|
type: 'libraryItem',
|
||||||
episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),
|
libraryItemId,
|
||||||
libraryItemId: libraryItem.id,
|
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : null,
|
||||||
libraryItem: libraryItem.toOldJSONMinified()
|
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
|
return json
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class PlaylistMediaItem extends Model {
|
||||||
|
|
||||||
// Expanded properties
|
// Expanded properties
|
||||||
|
|
||||||
/** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */
|
/** @type {import('./Book')|import('./PodcastEpisode')|import('./Series')} - only set when expanded */
|
||||||
this.mediaItem
|
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, {
|
book.hasMany(PlaylistMediaItem, {
|
||||||
foreignKey: 'mediaItemId',
|
foreignKey: 'mediaItemId',
|
||||||
|
|
@ -73,6 +73,15 @@ class PlaylistMediaItem extends Model {
|
||||||
})
|
})
|
||||||
PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
|
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) => {
|
PlaylistMediaItem.addHook('afterFind', (findResult) => {
|
||||||
if (!findResult) return
|
if (!findResult) return
|
||||||
|
|
||||||
|
|
@ -85,12 +94,17 @@ class PlaylistMediaItem extends Model {
|
||||||
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
||||||
instance.mediaItem = instance.podcastEpisode
|
instance.mediaItem = instance.podcastEpisode
|
||||||
instance.dataValues.mediaItem = instance.dataValues.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:
|
// To prevent mistakes:
|
||||||
delete instance.book
|
delete instance.book
|
||||||
delete instance.dataValues.book
|
delete instance.dataValues.book
|
||||||
delete instance.podcastEpisode
|
delete instance.podcastEpisode
|
||||||
delete instance.dataValues.podcastEpisode
|
delete instance.dataValues.podcastEpisode
|
||||||
|
delete instance.series
|
||||||
|
delete instance.dataValues.series
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ class ApiRouter {
|
||||||
this.router.delete('/collections/:id/book/:bookId', CollectionController.middleware.bind(this), CollectionController.removeBook.bind(this))
|
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/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.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
|
// 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.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/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.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))
|
this.router.post('/playlists/collection/:collectionId', PlaylistController.createFromCollection.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|
@ -445,9 +447,16 @@ class ApiRouter {
|
||||||
Database.removeSeriesFromFilterData(libraryId, id)
|
Database.removeSeriesFromFilterData(libraryId, id)
|
||||||
SocketAuthority.emitter('series_removed', { id: id, libraryId: libraryId })
|
SocketAuthority.emitter('series_removed', { id: id, libraryId: libraryId })
|
||||||
})
|
})
|
||||||
// Close rss feeds - remove from db and emit socket event
|
|
||||||
if (seriesToRemove.length) {
|
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) {
|
} catch (error) {
|
||||||
await transaction.rollback()
|
await transaction.rollback()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
585
test/server/controllers/PlaylistController.seriesEntries.test.js
Normal file
585
test/server/controllers/PlaylistController.seriesEntries.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue