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
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/CollectionSeriesItem')} */
|
||||
get collectionSeriesItemModel() {
|
||||
return this.models.collectionSeriesItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if db file exists
|
||||
* @returns {boolean}
|
||||
|
|
@ -336,6 +341,7 @@ class Database {
|
|||
require('./models/BookAuthor').init(this.sequelize)
|
||||
require('./models/Collection').init(this.sequelize)
|
||||
require('./models/CollectionBook').init(this.sequelize)
|
||||
require('./models/CollectionSeriesItem').init(this.sequelize)
|
||||
require('./models/Playlist').init(this.sequelize)
|
||||
require('./models/PlaylistMediaItem').init(this.sequelize)
|
||||
require('./models/Device').init(this.sequelize)
|
||||
|
|
@ -721,7 +727,7 @@ class Database {
|
|||
await libraryItem.destroy()
|
||||
}
|
||||
|
||||
// Remove invalid PlaylistMediaItem records
|
||||
// Remove invalid PlaylistMediaItem records (book/podcastEpisode types)
|
||||
const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({
|
||||
include: [
|
||||
{
|
||||
|
|
@ -734,6 +740,7 @@ class Database {
|
|||
}
|
||||
],
|
||||
where: {
|
||||
mediaItemType: ['book', 'podcastEpisode'],
|
||||
'$book.id$': null,
|
||||
'$podcastEpisode.id$': null
|
||||
}
|
||||
|
|
@ -743,6 +750,22 @@ class Database {
|
|||
await playlistMediaItem.destroy()
|
||||
}
|
||||
|
||||
// Remove invalid PlaylistMediaItem records (series type)
|
||||
const playlistSeriesItemsWithNoSeries = await this.playlistMediaItemModel.findAll({
|
||||
include: {
|
||||
model: this.seriesModel,
|
||||
attributes: ['id']
|
||||
},
|
||||
where: {
|
||||
mediaItemType: 'series',
|
||||
'$series.id$': null
|
||||
}
|
||||
})
|
||||
for (const playlistMediaItem of playlistSeriesItemsWithNoSeries) {
|
||||
Logger.warn(`Found playlistMediaItem with no series - removing it`)
|
||||
await playlistMediaItem.destroy()
|
||||
}
|
||||
|
||||
// Remove invalid CollectionBook records
|
||||
const collectionBooksWithNoBook = await this.collectionBookModel.findAll({
|
||||
include: {
|
||||
|
|
@ -756,6 +779,19 @@ class Database {
|
|||
await collectionBook.destroy()
|
||||
}
|
||||
|
||||
// Remove invalid CollectionSeriesItem records
|
||||
const collectionSeriesItemsWithNoSeries = await this.collectionSeriesItemModel.findAll({
|
||||
include: {
|
||||
model: this.seriesModel,
|
||||
required: false
|
||||
},
|
||||
where: { '$series.id$': null }
|
||||
})
|
||||
for (const collectionSeriesItem of collectionSeriesItemsWithNoSeries) {
|
||||
Logger.warn(`Found collectionSeriesItem with no series - removing it`)
|
||||
await collectionSeriesItem.destroy()
|
||||
}
|
||||
|
||||
// Remove empty series
|
||||
const emptySeries = await this.seriesModel.findAll({
|
||||
include: {
|
||||
|
|
|
|||
|
|
@ -42,21 +42,42 @@ class CollectionController {
|
|||
return res.status(400).send('Invalid collection description')
|
||||
}
|
||||
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. No books')
|
||||
const seriesIds = (reqBody.seriesIds || []).filter((id) => !!id && typeof id === 'string')
|
||||
const uniqueSeriesIds = [...new Set(seriesIds)]
|
||||
|
||||
// Must have at least one book or series
|
||||
if (!libraryItemIds.length && !uniqueSeriesIds.length) {
|
||||
return res.status(400).send('Invalid collection data. No books or series')
|
||||
}
|
||||
|
||||
// Load library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: libraryItemIds,
|
||||
libraryId: reqBody.libraryId,
|
||||
mediaType: 'book'
|
||||
// Load and validate library items
|
||||
let libraryItems = []
|
||||
if (libraryItemIds.length) {
|
||||
libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: libraryItemIds,
|
||||
libraryId: reqBody.libraryId,
|
||||
mediaType: 'book'
|
||||
}
|
||||
})
|
||||
if (libraryItems.length !== libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. Invalid books')
|
||||
}
|
||||
}
|
||||
|
||||
// Validate series exist in the same library
|
||||
if (uniqueSeriesIds.length) {
|
||||
const seriesRecords = await Database.seriesModel.findAll({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
id: uniqueSeriesIds,
|
||||
libraryId: reqBody.libraryId
|
||||
}
|
||||
})
|
||||
if (seriesRecords.length !== uniqueSeriesIds.length) {
|
||||
return res.status(400).send('Invalid collection data. Invalid series')
|
||||
}
|
||||
})
|
||||
if (libraryItems.length !== libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. Invalid books')
|
||||
}
|
||||
|
||||
/** @type {import('../models/Collection')} */
|
||||
|
|
@ -75,15 +96,28 @@ class CollectionController {
|
|||
)
|
||||
|
||||
// Create collectionBooks
|
||||
const collectionBookPayloads = libraryItemIds.map((llid, index) => {
|
||||
const libraryItem = libraryItems.find((li) => li.id === llid)
|
||||
return {
|
||||
let order = 1
|
||||
if (libraryItemIds.length) {
|
||||
const collectionBookPayloads = libraryItemIds.map((llid) => {
|
||||
const libraryItem = libraryItems.find((li) => li.id === llid)
|
||||
return {
|
||||
collectionId: newCollection.id,
|
||||
bookId: libraryItem.mediaId,
|
||||
order: order++
|
||||
}
|
||||
})
|
||||
await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })
|
||||
}
|
||||
|
||||
// Create collectionSeriesItems
|
||||
if (uniqueSeriesIds.length) {
|
||||
const collectionSeriesPayloads = uniqueSeriesIds.map((seriesId) => ({
|
||||
collectionId: newCollection.id,
|
||||
bookId: libraryItem.mediaId,
|
||||
order: index + 1
|
||||
}
|
||||
})
|
||||
await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })
|
||||
seriesId,
|
||||
order: order++
|
||||
}))
|
||||
await Database.collectionSeriesItemModel.bulkCreate(collectionSeriesPayloads, { transaction })
|
||||
}
|
||||
|
||||
await transaction.commit()
|
||||
} catch (error) {
|
||||
|
|
@ -92,10 +126,10 @@ class CollectionController {
|
|||
return res.status(500).send('Failed to create collection')
|
||||
}
|
||||
|
||||
// Load books expanded
|
||||
// Load books and series expanded
|
||||
newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()
|
||||
newCollection.collectionSeriesItems = await newCollection.getSeriesItemsExpanded()
|
||||
|
||||
// Note: The old collection model stores expanded libraryItems in the books property
|
||||
const jsonExpanded = newCollection.toOldJSONExpanded()
|
||||
SocketAuthority.emitter('collection_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
|
|
@ -313,29 +347,55 @@ class CollectionController {
|
|||
async addBatch(req, res) {
|
||||
// filter out invalid libraryItemIds
|
||||
const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
|
||||
if (!bookIdsToAdd.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
const seriesIdsToAdd = (req.body.seriesIds || []).filter((id) => !!id && typeof id === 'string')
|
||||
const uniqueSeriesIds = [...new Set(seriesIdsToAdd)]
|
||||
|
||||
if (!bookIdsToAdd.length && !uniqueSeriesIds.length) {
|
||||
return res.status(400).send('Invalid request body. No books or series')
|
||||
}
|
||||
|
||||
// Get library items associated with ids
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: bookIdsToAdd,
|
||||
libraryId: req.collection.libraryId,
|
||||
mediaType: 'book'
|
||||
let libraryItems = []
|
||||
if (bookIdsToAdd.length) {
|
||||
libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: bookIdsToAdd,
|
||||
libraryId: req.collection.libraryId,
|
||||
mediaType: 'book'
|
||||
}
|
||||
})
|
||||
if (!libraryItems.length && !uniqueSeriesIds.length) {
|
||||
return res.status(400).send('Invalid request body. No valid books')
|
||||
}
|
||||
})
|
||||
if (!libraryItems.length) {
|
||||
return res.status(400).send('Invalid request body. No valid books')
|
||||
}
|
||||
|
||||
// Get collection books already in collection
|
||||
// Validate series
|
||||
if (uniqueSeriesIds.length) {
|
||||
const seriesRecords = await Database.seriesModel.findAll({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
id: uniqueSeriesIds,
|
||||
libraryId: req.collection.libraryId
|
||||
}
|
||||
})
|
||||
if (seriesRecords.length !== uniqueSeriesIds.length) {
|
||||
return res.status(400).send('Invalid request body. Invalid series')
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing entries in collection
|
||||
/** @type {import('../models/CollectionBook')[]} */
|
||||
const collectionBooks = await req.collection.getCollectionBooks()
|
||||
const collectionSeriesItems = await req.collection.getCollectionSeriesItems()
|
||||
|
||||
// Compute max order across both tables
|
||||
const maxBookOrder = collectionBooks.length ? Math.max(...collectionBooks.map((cb) => cb.order)) : 0
|
||||
const maxSeriesOrder = collectionSeriesItems.length ? Math.max(...collectionSeriesItems.map((csi) => csi.order)) : 0
|
||||
let order = Math.max(maxBookOrder, maxSeriesOrder) + 1
|
||||
|
||||
let order = collectionBooks.length + 1
|
||||
const collectionBooksToAdd = []
|
||||
const collectionSeriesToAdd = []
|
||||
let hasUpdated = false
|
||||
|
||||
// Check and set new collection books to add
|
||||
|
|
@ -352,14 +412,32 @@ class CollectionController {
|
|||
}
|
||||
}
|
||||
|
||||
let jsonExpanded = null
|
||||
if (hasUpdated) {
|
||||
await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
|
||||
// Check and set new collection series to add
|
||||
for (const seriesId of uniqueSeriesIds) {
|
||||
if (!collectionSeriesItems.some((csi) => csi.seriesId === seriesId)) {
|
||||
collectionSeriesToAdd.push({
|
||||
collectionId: req.collection.id,
|
||||
seriesId,
|
||||
order: order++
|
||||
})
|
||||
hasUpdated = true
|
||||
} else {
|
||||
Logger.warn(`[CollectionController] addBatch: Series ${seriesId} already in collection`)
|
||||
}
|
||||
}
|
||||
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
if (hasUpdated) {
|
||||
if (collectionBooksToAdd.length) {
|
||||
await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
|
||||
}
|
||||
if (collectionSeriesToAdd.length) {
|
||||
await Database.collectionSeriesItemModel.bulkCreate(collectionSeriesToAdd)
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
if (hasUpdated) {
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
} else {
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
|
@ -419,6 +497,52 @@ class CollectionController {
|
|||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/collections/:id/series/:seriesId
|
||||
* Remove a series entry from collection
|
||||
*
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeSeries(req, res) {
|
||||
const collectionSeriesItem = await Database.collectionSeriesItemModel.findOne({
|
||||
where: {
|
||||
collectionId: req.collection.id,
|
||||
seriesId: req.params.seriesId
|
||||
}
|
||||
})
|
||||
|
||||
if (!collectionSeriesItem) {
|
||||
// Idempotent - return 200 if series not in collection
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
return res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// Remove record
|
||||
await collectionSeriesItem.destroy()
|
||||
|
||||
// Re-compact orders across both tables
|
||||
const collectionBooks = await req.collection.getCollectionBooks({ order: [['order', 'ASC']] })
|
||||
const remainingSeriesItems = await req.collection.getCollectionSeriesItems({ order: [['order', 'ASC']] })
|
||||
|
||||
// Merge and sort by current order
|
||||
const allEntries = [
|
||||
...collectionBooks.map((cb) => ({ record: cb, order: cb.order })),
|
||||
...remainingSeriesItems.map((csi) => ({ record: csi, order: csi.order }))
|
||||
].sort((a, b) => a.order - b.order)
|
||||
|
||||
// Reassign sequential orders
|
||||
for (let i = 0; i < allEntries.length; i++) {
|
||||
if (allEntries[i].record.order !== i + 1) {
|
||||
await allEntries[i].record.update({ order: i + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
|
|
@ -434,8 +558,8 @@ class CollectionController {
|
|||
req.collection = collection
|
||||
}
|
||||
|
||||
// Users with update permission can remove books from collections
|
||||
if (req.method == 'DELETE' && !req.params.bookId && !req.user.canDelete) {
|
||||
// Users with update permission can remove books/series from collections
|
||||
if (req.method == 'DELETE' && !req.params.bookId && !req.params.seriesId && !req.user.canDelete) {
|
||||
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to delete without permission`)
|
||||
return res.sendStatus(403)
|
||||
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,19 @@ class PlaylistController {
|
|||
return res.status(400).send('Invalid playlist description')
|
||||
}
|
||||
const items = reqBody.items || []
|
||||
const seriesIds = (reqBody.seriesIds || []).filter((id) => !!id && typeof id === 'string')
|
||||
const isPodcast = items.some((i) => i.episodeId)
|
||||
|
||||
// Must have at least one item or series
|
||||
if (!items.length && !seriesIds.length) {
|
||||
return res.status(400).send('Invalid playlist data. No items or series')
|
||||
}
|
||||
|
||||
// Series entries not valid for podcast playlists
|
||||
if (isPodcast && seriesIds.length) {
|
||||
return res.status(400).send('Series entries are not valid for podcast playlists')
|
||||
}
|
||||
|
||||
const libraryItemIds = new Set()
|
||||
for (const item of items) {
|
||||
if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
|
||||
|
|
@ -53,16 +65,19 @@ class PlaylistController {
|
|||
}
|
||||
|
||||
// Load library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: Array.from(libraryItemIds),
|
||||
libraryId: reqBody.libraryId,
|
||||
mediaType: isPodcast ? 'podcast' : 'book'
|
||||
let libraryItems = []
|
||||
if (libraryItemIds.size) {
|
||||
libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: Array.from(libraryItemIds),
|
||||
libraryId: reqBody.libraryId,
|
||||
mediaType: isPodcast ? 'podcast' : 'book'
|
||||
}
|
||||
})
|
||||
if (libraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid playlist data. Invalid items')
|
||||
}
|
||||
})
|
||||
if (libraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid playlist data. Invalid items')
|
||||
}
|
||||
|
||||
// Validate podcast episodes
|
||||
|
|
@ -79,6 +94,21 @@ class PlaylistController {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate series exist in the same library
|
||||
const uniqueSeriesIds = [...new Set(seriesIds)]
|
||||
if (uniqueSeriesIds.length) {
|
||||
const seriesRecords = await Database.seriesModel.findAll({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
id: uniqueSeriesIds,
|
||||
libraryId: reqBody.libraryId
|
||||
}
|
||||
})
|
||||
if (seriesRecords.length !== uniqueSeriesIds.length) {
|
||||
return res.status(400).send('Invalid playlist data. Invalid series')
|
||||
}
|
||||
}
|
||||
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
// Create playlist
|
||||
|
|
@ -92,15 +122,26 @@ class PlaylistController {
|
|||
{ transaction }
|
||||
)
|
||||
|
||||
// Create playlistMediaItems
|
||||
// Create playlistMediaItems for books/episodes
|
||||
const playlistItemPayloads = []
|
||||
for (const [index, item] of items.entries()) {
|
||||
let order = 1
|
||||
for (const item of items) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
playlistItemPayloads.push({
|
||||
playlistId: newPlaylist.id,
|
||||
mediaItemId: item.episodeId || libraryItem.mediaId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: index + 1
|
||||
order: order++
|
||||
})
|
||||
}
|
||||
|
||||
// Create playlistMediaItems for series
|
||||
for (const seriesId of uniqueSeriesIds) {
|
||||
playlistItemPayloads.push({
|
||||
playlistId: newPlaylist.id,
|
||||
mediaItemId: seriesId,
|
||||
mediaItemType: 'series',
|
||||
order: order++
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -367,8 +408,8 @@ class PlaylistController {
|
|||
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
// Playlist is removed when there are no items
|
||||
if (!jsonExpanded.items.length) {
|
||||
// Playlist is removed when there are no entries (items + series)
|
||||
if (!req.playlist.playlistMediaItems.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
|
|
@ -387,26 +428,57 @@ class PlaylistController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async addBatch(req, res) {
|
||||
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
const items = req.body.items || []
|
||||
const seriesIds = (req.body.seriesIds || []).filter((id) => !!id && typeof id === 'string')
|
||||
|
||||
// Validate items if provided
|
||||
if (items.length) {
|
||||
if (!Array.isArray(items) || items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
}
|
||||
|
||||
if (!items.length && !seriesIds.length) {
|
||||
return res.status(400).send('Invalid request body. No items or series')
|
||||
}
|
||||
|
||||
// Find all library items
|
||||
const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
|
||||
let libraryItems = []
|
||||
if (items.length) {
|
||||
const libraryItemIds = new Set(items.map((i) => i.libraryItemId).filter((i) => i))
|
||||
libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) })
|
||||
if (libraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
}
|
||||
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) })
|
||||
if (libraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
// Validate series
|
||||
const uniqueSeriesIds = [...new Set(seriesIds)]
|
||||
if (uniqueSeriesIds.length) {
|
||||
// Series not valid for podcast playlists
|
||||
const isPodcast = items.some((i) => i.episodeId)
|
||||
if (isPodcast) {
|
||||
return res.status(400).send('Series entries are not valid for podcast playlists')
|
||||
}
|
||||
const seriesRecords = await Database.seriesModel.findAll({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
id: uniqueSeriesIds,
|
||||
libraryId: req.playlist.libraryId
|
||||
}
|
||||
})
|
||||
if (seriesRecords.length !== uniqueSeriesIds.length) {
|
||||
return res.status(400).send('Invalid request body. Invalid series')
|
||||
}
|
||||
}
|
||||
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const mediaItemsToAdd = []
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
// Setup array of playlistMediaItem records to add
|
||||
let order = req.playlist.playlistMediaItems.length + 1
|
||||
for (const item of req.body.items) {
|
||||
for (const item of items) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
|
||||
const mediaItemId = item.episodeId || libraryItem.media.id
|
||||
|
|
@ -420,28 +492,31 @@ class PlaylistController {
|
|||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
})
|
||||
|
||||
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
|
||||
if (item.episodeId) {
|
||||
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === item.episodeId)
|
||||
jsonExpanded.items.push({
|
||||
episodeId: item.episodeId,
|
||||
episode: episode.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONMinified()
|
||||
})
|
||||
} else {
|
||||
jsonExpanded.items.push({
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add series entries (skip duplicates)
|
||||
for (const seriesId of uniqueSeriesIds) {
|
||||
if (req.playlist.checkHasSeries(seriesId)) {
|
||||
continue
|
||||
}
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId: seriesId,
|
||||
mediaItemType: 'series',
|
||||
order: order++
|
||||
})
|
||||
}
|
||||
|
||||
if (mediaItemsToAdd.length) {
|
||||
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
|
||||
}
|
||||
|
||||
// Reload fully to get consistent response with entries
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
if (mediaItemsToAdd.length) {
|
||||
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
|
||||
|
|
@ -557,6 +632,50 @@ class PlaylistController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/playlists/:id/series/:seriesId
|
||||
* Remove a series entry from playlist
|
||||
*
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeSeries(req, res) {
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemType === 'series' && pmi.mediaItemId === req.params.seriesId)
|
||||
if (!playlistMediaItem) {
|
||||
// Idempotent - return 200 if series not in playlist
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
return res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// Remove record
|
||||
await playlistMediaItem.destroy()
|
||||
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
|
||||
|
||||
// Update playlist media items order
|
||||
for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
|
||||
if (mediaItem.order !== index + 1) {
|
||||
await mediaItem.update({
|
||||
order: index + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
// Playlist is removed when there are no entries
|
||||
if (!req.playlist.playlistMediaItems.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
} else {
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
|
|
|
|||
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 */
|
||||
this.books
|
||||
/** @type {import('./CollectionSeriesItem')[]} - only set when expanded */
|
||||
this.collectionSeriesItems
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -70,6 +72,12 @@ class Collection extends Model {
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.collectionSeriesItem,
|
||||
include: {
|
||||
model: this.sequelize.models.series
|
||||
}
|
||||
},
|
||||
...collectionIncludes
|
||||
],
|
||||
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
|
|
@ -92,11 +100,12 @@ class Collection extends Model {
|
|||
}) || []
|
||||
|
||||
// Users with restricted permissions will not see this collection
|
||||
if (!books.length && c.books.length) {
|
||||
// (only if collection has books but all are filtered out)
|
||||
if (!books.length && c.books.length && !c.collectionSeriesItems?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.books = books
|
||||
c.books = books
|
||||
|
||||
const collectionExpanded = c.toOldJSONExpanded()
|
||||
|
||||
|
|
@ -137,6 +146,12 @@ class Collection extends Model {
|
|||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.collectionSeriesItem,
|
||||
include: {
|
||||
model: this.sequelize.models.series
|
||||
}
|
||||
}
|
||||
],
|
||||
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
|
|
@ -212,6 +227,20 @@ class Collection extends Model {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get series items in collection expanded with series data
|
||||
*
|
||||
* @returns {Promise<import('./CollectionSeriesItem')[]>}
|
||||
*/
|
||||
getSeriesItemsExpanded() {
|
||||
return this.getCollectionSeriesItems({
|
||||
include: {
|
||||
model: this.sequelize.models.series
|
||||
},
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get toOldJSONExpanded, items filtered for user permissions
|
||||
*
|
||||
|
|
@ -221,6 +250,7 @@ class Collection extends Model {
|
|||
*/
|
||||
async getOldJsonExpanded(user, include) {
|
||||
this.books = await this.getBooksExpandedWithLibraryItem()
|
||||
this.collectionSeriesItems = await this.getSeriesItemsExpanded()
|
||||
|
||||
// Filter books using user permissions
|
||||
// TODO: Handle user permission restrictions on initial query
|
||||
|
|
@ -236,7 +266,8 @@ class Collection extends Model {
|
|||
})
|
||||
|
||||
// Users with restricted permissions will not see this collection
|
||||
if (!books.length && this.books.length) {
|
||||
// (only if collection has books but all are filtered out and no series entries)
|
||||
if (!books.length && this.books.length && !this.collectionSeriesItems?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -278,6 +309,22 @@ class Collection extends Model {
|
|||
}
|
||||
|
||||
const json = this.toOldJSON()
|
||||
|
||||
// Build entries first (before books processing mutates book.libraryItem)
|
||||
const bookEntries = this.books.map((book) => ({
|
||||
type: 'libraryItem',
|
||||
libraryItemId: book.libraryItem?.id || null,
|
||||
order: book.collectionBook?.order || 0
|
||||
}))
|
||||
const seriesEntries = (this.collectionSeriesItems || []).map((csi) => ({
|
||||
type: 'series',
|
||||
seriesId: csi.seriesId,
|
||||
seriesName: csi.series?.name || null,
|
||||
order: csi.order
|
||||
}))
|
||||
json.entries = [...bookEntries, ...seriesEntries].sort((a, b) => a.order - b.order)
|
||||
|
||||
// books: backward-compatible array with only book entries (destructive to book.libraryItem)
|
||||
json.books = this.books.map((book) => {
|
||||
const libraryItem = book.libraryItem
|
||||
delete book.libraryItem
|
||||
|
|
|
|||
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,
|
||||
include: this.sequelize.models.libraryItem
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -132,6 +135,9 @@ class Playlist extends Model {
|
|||
model: this.sequelize.models.podcast,
|
||||
include: this.sequelize.models.libraryItem
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -152,11 +158,16 @@ class Playlist extends Model {
|
|||
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
|
||||
pmi.mediaItem = pmi.podcastEpisode
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
|
||||
} else if (pmi.mediaItemType === 'series' && pmi.series !== undefined) {
|
||||
pmi.mediaItem = pmi.series
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.series
|
||||
}
|
||||
delete pmi.book
|
||||
delete pmi.dataValues.book
|
||||
delete pmi.podcastEpisode
|
||||
delete pmi.dataValues.podcastEpisode
|
||||
delete pmi.series
|
||||
delete pmi.dataValues.series
|
||||
return pmi
|
||||
})
|
||||
playlists.push(playlist)
|
||||
|
|
@ -207,6 +218,64 @@ class Playlist extends Model {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes series entries from playlists and re-orders
|
||||
*
|
||||
* @param {string[]} seriesIds
|
||||
*/
|
||||
static async removeSeriesFromPlaylists(seriesIds) {
|
||||
if (!seriesIds?.length) return
|
||||
|
||||
const playlistMediaItems = await this.sequelize.models.playlistMediaItem.findAll({
|
||||
where: {
|
||||
mediaItemType: 'series',
|
||||
mediaItemId: {
|
||||
[Op.in]: seriesIds
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: this.sequelize.models.playlist
|
||||
}
|
||||
})
|
||||
|
||||
if (!playlistMediaItems.length) return
|
||||
|
||||
// Group by playlist
|
||||
const playlistMap = new Map()
|
||||
for (const pmi of playlistMediaItems) {
|
||||
if (!playlistMap.has(pmi.playlist.id)) {
|
||||
playlistMap.set(pmi.playlist.id, { playlist: pmi.playlist, pmiIds: [] })
|
||||
}
|
||||
playlistMap.get(pmi.playlist.id).pmiIds.push(pmi.id)
|
||||
await pmi.destroy()
|
||||
}
|
||||
|
||||
for (const { playlist, pmiIds } of playlistMap.values()) {
|
||||
// Re-order remaining items
|
||||
const remainingPmis = await this.sequelize.models.playlistMediaItem.findAll({
|
||||
where: { playlistId: playlist.id },
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
||||
let order = 1
|
||||
for (const pmi of remainingPmis) {
|
||||
if (pmi.order !== order) {
|
||||
await pmi.update({ order })
|
||||
}
|
||||
order++
|
||||
}
|
||||
|
||||
const jsonExpanded = await playlist.getOldJsonExpanded()
|
||||
if (!remainingPmis.length) {
|
||||
Logger.info(`[Playlist] Playlist "${playlist.name}" has no more items - removing it`)
|
||||
await playlist.destroy()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
} else {
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
|
|
@ -251,12 +320,17 @@ class Playlist extends Model {
|
|||
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
|
||||
pmi.mediaItem = pmi.podcastEpisode
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
|
||||
} else if (pmi.mediaItemType === 'series' && pmi.series !== undefined) {
|
||||
pmi.mediaItem = pmi.series
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.series
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete pmi.book
|
||||
delete pmi.dataValues.book
|
||||
delete pmi.podcastEpisode
|
||||
delete pmi.dataValues.podcastEpisode
|
||||
delete pmi.series
|
||||
delete pmi.dataValues.series
|
||||
return pmi
|
||||
})
|
||||
}
|
||||
|
|
@ -300,6 +374,9 @@ class Playlist extends Model {
|
|||
include: this.sequelize.models.libraryItem
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series
|
||||
}
|
||||
],
|
||||
order: [['order', 'ASC']]
|
||||
|
|
@ -329,7 +406,20 @@ class Playlist extends Model {
|
|||
if (episodeId) {
|
||||
return this.playlistMediaItems.some((pmi) => pmi.mediaItemId === episodeId)
|
||||
}
|
||||
return this.playlistMediaItems.some((pmi) => pmi.mediaItem.libraryItem.id === libraryItemId)
|
||||
return this.playlistMediaItems.some((pmi) => pmi.mediaItem?.libraryItem?.id === libraryItemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if playlist has a series entry
|
||||
*
|
||||
* @param {string} seriesId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkHasSeries(seriesId) {
|
||||
if (!this.playlistMediaItems) {
|
||||
throw new Error('playlistMediaItems are required to check Playlist')
|
||||
}
|
||||
return this.playlistMediaItems.some((pmi) => pmi.mediaItemType === 'series' && pmi.mediaItemId === seriesId)
|
||||
}
|
||||
|
||||
toOldJSON() {
|
||||
|
|
@ -350,28 +440,58 @@ class Playlist extends Model {
|
|||
}
|
||||
|
||||
const json = this.toOldJSON()
|
||||
json.items = this.playlistMediaItems.map((pmi) => {
|
||||
if (pmi.mediaItemType === 'book') {
|
||||
const libraryItem = pmi.mediaItem.libraryItem
|
||||
delete pmi.mediaItem.libraryItem
|
||||
libraryItem.media = pmi.mediaItem
|
||||
|
||||
// Build entries first (before items processing mutates pmi.mediaItem)
|
||||
json.entries = this.playlistMediaItems.map((pmi) => {
|
||||
if (pmi.mediaItemType === 'series') {
|
||||
return {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
type: 'series',
|
||||
seriesId: pmi.mediaItemId,
|
||||
seriesName: pmi.mediaItem.name,
|
||||
order: pmi.order
|
||||
}
|
||||
}
|
||||
|
||||
const libraryItem = pmi.mediaItem.podcast.libraryItem
|
||||
delete pmi.mediaItem.podcast.libraryItem
|
||||
libraryItem.media = pmi.mediaItem.podcast
|
||||
// libraryItem entry (book or podcastEpisode)
|
||||
let libraryItemId = null
|
||||
if (pmi.mediaItemType === 'book') {
|
||||
libraryItemId = pmi.mediaItem?.libraryItem?.id || null
|
||||
} else if (pmi.mediaItemType === 'podcastEpisode') {
|
||||
libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || null
|
||||
}
|
||||
return {
|
||||
episodeId: pmi.mediaItemId,
|
||||
episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONMinified()
|
||||
type: 'libraryItem',
|
||||
libraryItemId,
|
||||
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : null,
|
||||
order: pmi.order
|
||||
}
|
||||
})
|
||||
|
||||
// items: backward-compatible array with only book/episode entries (destructive to pmi.mediaItem)
|
||||
json.items = this.playlistMediaItems
|
||||
.filter((pmi) => pmi.mediaItemType !== 'series')
|
||||
.map((pmi) => {
|
||||
if (pmi.mediaItemType === 'book') {
|
||||
const libraryItem = pmi.mediaItem.libraryItem
|
||||
delete pmi.mediaItem.libraryItem
|
||||
libraryItem.media = pmi.mediaItem
|
||||
return {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
}
|
||||
}
|
||||
|
||||
const libraryItem = pmi.mediaItem.podcast.libraryItem
|
||||
delete pmi.mediaItem.podcast.libraryItem
|
||||
libraryItem.media = pmi.mediaItem.podcast
|
||||
return {
|
||||
episodeId: pmi.mediaItemId,
|
||||
episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toOldJSONMinified()
|
||||
}
|
||||
})
|
||||
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ class PlaylistMediaItem extends Model {
|
|||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */
|
||||
/** @type {import('./Book')|import('./PodcastEpisode')|import('./Series')} - only set when expanded */
|
||||
this.mediaItem
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ class PlaylistMediaItem extends Model {
|
|||
}
|
||||
)
|
||||
|
||||
const { book, podcastEpisode, playlist } = sequelize.models
|
||||
const { book, podcastEpisode, playlist, series } = sequelize.models
|
||||
|
||||
book.hasMany(PlaylistMediaItem, {
|
||||
foreignKey: 'mediaItemId',
|
||||
|
|
@ -73,6 +73,15 @@ class PlaylistMediaItem extends Model {
|
|||
})
|
||||
PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
series.hasMany(PlaylistMediaItem, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'series'
|
||||
}
|
||||
})
|
||||
PlaylistMediaItem.belongsTo(series, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
PlaylistMediaItem.addHook('afterFind', (findResult) => {
|
||||
if (!findResult) return
|
||||
|
||||
|
|
@ -85,12 +94,17 @@ class PlaylistMediaItem extends Model {
|
|||
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
||||
instance.mediaItem = instance.podcastEpisode
|
||||
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
|
||||
} else if (instance.mediaItemType === 'series' && instance.series !== undefined) {
|
||||
instance.mediaItem = instance.series
|
||||
instance.dataValues.mediaItem = instance.dataValues.series
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.book
|
||||
delete instance.dataValues.book
|
||||
delete instance.podcastEpisode
|
||||
delete instance.dataValues.podcastEpisode
|
||||
delete instance.series
|
||||
delete instance.dataValues.series
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ class ApiRouter {
|
|||
this.router.delete('/collections/:id/book/:bookId', CollectionController.middleware.bind(this), CollectionController.removeBook.bind(this))
|
||||
this.router.post('/collections/:id/batch/add', CollectionController.middleware.bind(this), CollectionController.addBatch.bind(this))
|
||||
this.router.post('/collections/:id/batch/remove', CollectionController.middleware.bind(this), CollectionController.removeBatch.bind(this))
|
||||
this.router.delete('/collections/:id/series/:seriesId', CollectionController.middleware.bind(this), CollectionController.removeSeries.bind(this))
|
||||
|
||||
//
|
||||
// Playlist Routes
|
||||
|
|
@ -165,6 +166,7 @@ class ApiRouter {
|
|||
this.router.delete('/playlists/:id/item/:libraryItemId/:episodeId?', PlaylistController.middleware.bind(this), PlaylistController.removeItem.bind(this))
|
||||
this.router.post('/playlists/:id/batch/add', PlaylistController.middleware.bind(this), PlaylistController.addBatch.bind(this))
|
||||
this.router.post('/playlists/:id/batch/remove', PlaylistController.middleware.bind(this), PlaylistController.removeBatch.bind(this))
|
||||
this.router.delete('/playlists/:id/series/:seriesId', PlaylistController.middleware.bind(this), PlaylistController.removeSeries.bind(this))
|
||||
this.router.post('/playlists/collection/:collectionId', PlaylistController.createFromCollection.bind(this))
|
||||
|
||||
//
|
||||
|
|
@ -445,9 +447,16 @@ class ApiRouter {
|
|||
Database.removeSeriesFromFilterData(libraryId, id)
|
||||
SocketAuthority.emitter('series_removed', { id: id, libraryId: libraryId })
|
||||
})
|
||||
// Close rss feeds - remove from db and emit socket event
|
||||
if (seriesToRemove.length) {
|
||||
await RssFeedManager.closeFeedsForEntityIds(seriesToRemove.map((s) => s.id))
|
||||
const removedSeriesIds = seriesToRemove.map((s) => s.id)
|
||||
|
||||
// Clean up series entries from playlists (no FK constraint due to polymorphic pattern)
|
||||
await Database.playlistModel.removeSeriesFromPlaylists(removedSeriesIds)
|
||||
|
||||
// CollectionSeriesItem records are cleaned up by ON DELETE CASCADE
|
||||
|
||||
// Close rss feeds - remove from db and emit socket event
|
||||
await RssFeedManager.closeFeedsForEntityIds(removedSeriesIds)
|
||||
}
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
|
|
|
|||
|
|
@ -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