diff --git a/server/Database.js b/server/Database.js index bf04529f..bfe7918f 100644 --- a/server/Database.js +++ b/server/Database.js @@ -275,42 +275,11 @@ class Database { } } - updateCollection(oldCollection) { - if (!this.sequelize) return false - const collectionBooks = [] - let order = 1 - oldCollection.books.forEach((libraryItemId) => { - const libraryItem = this.getLibraryItem(libraryItemId) - if (!libraryItem) return - collectionBooks.push({ - collectionId: oldCollection.id, - bookId: libraryItem.media.id, - order: order++ - }) - }) - return this.models.collection.fullUpdateFromOld(oldCollection, collectionBooks) - } - - async removeCollection(collectionId) { - if (!this.sequelize) return false - await this.models.collection.removeById(collectionId) - } - - createCollectionBook(collectionBook) { - if (!this.sequelize) return false - return this.models.collectionBook.create(collectionBook) - } - createBulkCollectionBooks(collectionBooks) { if (!this.sequelize) return false return this.models.collectionBook.bulkCreate(collectionBooks) } - removeCollectionBook(collectionId, bookId) { - if (!this.sequelize) return false - return this.models.collectionBook.removeByIds(collectionId, bookId) - } - async createPlaylist(oldPlaylist) { if (!this.sequelize) return false const newPlaylist = await this.models.playlist.createFromOld(oldPlaylist) diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js index 30bb2a61..7b297208 100644 --- a/server/controllers/CollectionController.js +++ b/server/controllers/CollectionController.js @@ -1,3 +1,4 @@ +const Sequelize = require('sequelize') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') @@ -7,16 +8,43 @@ const Collection = require('../objects/Collection') class CollectionController { constructor() { } + /** + * POST: /api/collections + * Create new collection + * @param {*} req + * @param {*} res + */ async create(req, res) { const newCollection = new Collection() req.body.userId = req.user.id if (!newCollection.setData(req.body)) { - return res.status(500).send('Invalid collection data') + return res.status(400).send('Invalid collection data') } + // Create collection record + await Database.models.collection.createFromOld(newCollection) + + // Get library items in collection const libraryItemsInCollection = await Database.models.libraryItem.getForCollection(newCollection) + + // Create collectionBook records + let order = 1 + const collectionBooksToAdd = [] + for (const libraryItemId of newCollection.books) { + const libraryItem = libraryItemsInCollection.find(li => li.id === libraryItemId) + if (libraryItem) { + collectionBooksToAdd.push({ + collectionId: newCollection.id, + bookId: libraryItem.media.id, + order: order++ + }) + } + } + if (collectionBooksToAdd.length) { + await Database.createBulkCollectionBooks(collectionBooksToAdd) + } + const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection) - await Database.createCollection(newCollection) SocketAuthority.emitter('collection_added', jsonExpanded) res.json(jsonExpanded) } @@ -31,140 +59,275 @@ class CollectionController { async findOne(req, res) { const includeEntities = (req.query.include || '').split(',') - const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems) - - if (includeEntities.includes('rssfeed')) { - const feedData = await this.rssFeedManager.findFeedForEntityId(collectionExpanded.id) - collectionExpanded.rssFeed = feedData?.toJSONMinified() || null + const collectionExpanded = await req.collection.getOldJsonExpanded(req.user, includeEntities) + if (!collectionExpanded) { + // This may happen if the user is restricted from all books + return res.sendStatus(404) } res.json(collectionExpanded) } + /** + * PATCH: /api/collections/:id + * Update collection + * @param {*} req + * @param {*} res + */ async update(req, res) { - const collection = req.collection - const wasUpdated = collection.update(req.body) - const jsonExpanded = collection.toJSONExpanded(Database.libraryItems) + let wasUpdated = false + + // Update description and name if defined + const collectionUpdatePayload = {} + if (req.body.description !== undefined && req.body.description !== req.collection.description) { + collectionUpdatePayload.description = req.body.description + wasUpdated = true + } + if (req.body.name !== undefined && req.body.name !== req.collection.name) { + collectionUpdatePayload.name = req.body.name + wasUpdated = true + } + + if (wasUpdated) { + await req.collection.update(collectionUpdatePayload) + } + + // If books array is passed in then update order in collection + if (req.body.books?.length) { + const collectionBooks = await req.collection.getCollectionBooks({ + include: { + model: Database.models.book, + include: Database.models.libraryItem + }, + order: [['order', 'ASC']] + }) + collectionBooks.sort((a, b) => { + const aIndex = req.body.books.findIndex(lid => lid === a.book.libraryItem.id) + const bIndex = req.body.books.findIndex(lid => lid === b.book.libraryItem.id) + return aIndex - bIndex + }) + for (let i = 0; i < collectionBooks.length; i++) { + if (collectionBooks[i].order !== i + 1) { + await collectionBooks[i].update({ + order: i + 1 + }) + wasUpdated = true + } + } + } + + const jsonExpanded = await req.collection.getOldJsonExpanded() if (wasUpdated) { - await Database.updateCollection(collection) SocketAuthority.emitter('collection_updated', jsonExpanded) } res.json(jsonExpanded) } async delete(req, res) { - const collection = req.collection - const jsonExpanded = collection.toJSONExpanded(Database.libraryItems) + const jsonExpanded = await req.collection.getOldJsonExpanded() // Close rss feed - remove from db and emit socket event - await this.rssFeedManager.closeFeedForEntityId(collection.id) + await this.rssFeedManager.closeFeedForEntityId(req.collection.id) + + await req.collection.destroy() - await Database.removeCollection(collection.id) SocketAuthority.emitter('collection_removed', jsonExpanded) res.sendStatus(200) } + /** + * POST: /api/collections/:id/book + * Add a single book to a collection + * Req.body { id: } + * @param {*} req + * @param {*} res + */ async addBook(req, res) { - const collection = req.collection - const libraryItem = Database.libraryItems.find(li => li.id === req.body.id) + const libraryItem = await Database.models.libraryItem.getOldById(req.body.id) if (!libraryItem) { - return res.status(500).send('Book not found') + return res.status(404).send('Book not found') } - if (libraryItem.libraryId !== collection.libraryId) { - return res.status(500).send('Book in different library') + if (libraryItem.libraryId !== req.collection.libraryId) { + return res.status(400).send('Book in different library') } - if (collection.books.includes(req.body.id)) { - return res.status(500).send('Book already in collection') - } - collection.addBook(req.body.id) - const jsonExpanded = collection.toJSONExpanded(Database.libraryItems) - const collectionBook = { - collectionId: collection.id, - bookId: libraryItem.media.id, - order: collection.books.length + // Check if book is already in collection + const collectionBooks = await req.collection.getCollectionBooks() + if (collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) { + return res.status(400).send('Book already in collection') } - await Database.createCollectionBook(collectionBook) + + // Create collectionBook record + await Database.models.collectionBook.create({ + collectionId: req.collection.id, + bookId: libraryItem.media.id, + order: collectionBooks.length + 1 + }) + const jsonExpanded = await req.collection.getOldJsonExpanded() SocketAuthority.emitter('collection_updated', jsonExpanded) res.json(jsonExpanded) } - // DELETE: api/collections/:id/book/:bookId + /** + * DELETE: /api/collections/:id/book/:bookId + * Remove a single book from a collection. Re-order books + * TODO: bookId is actually libraryItemId. Clients need updating to use bookId + * @param {*} req + * @param {*} res + */ async removeBook(req, res) { - const collection = req.collection - const libraryItem = Database.libraryItems.find(li => li.id === req.params.bookId) + const libraryItem = await Database.models.libraryItem.getOldById(req.params.bookId) if (!libraryItem) { return res.sendStatus(404) } - if (collection.books.includes(req.params.bookId)) { - collection.removeBook(req.params.bookId) - const jsonExpanded = collection.toJSONExpanded(Database.libraryItems) + // Get books in collection ordered + const collectionBooks = await req.collection.getCollectionBooks({ + order: [['order', 'ASC']] + }) + + let jsonExpanded = null + const collectionBookToRemove = collectionBooks.find(cb => cb.bookId === libraryItem.media.id) + if (collectionBookToRemove) { + // Remove collection book record + await collectionBookToRemove.destroy() + + // Update order on collection books + let order = 1 + for (const collectionBook of collectionBooks) { + if (collectionBook.bookId === libraryItem.media.id) continue + if (collectionBook.order !== order) { + await collectionBook.update({ + order + }) + } + order++ + } + + jsonExpanded = await req.collection.getOldJsonExpanded() SocketAuthority.emitter('collection_updated', jsonExpanded) - await Database.updateCollection(collection) + } else { + jsonExpanded = await req.collection.getOldJsonExpanded() } - res.json(collection.toJSONExpanded(Database.libraryItems)) + res.json(jsonExpanded) } - // POST: api/collections/:id/batch/add + /** + * POST: /api/collections/:id/batch/add + * Add multiple books to collection + * Req.body { books: } + * @param {*} req + * @param {*} res + */ async addBatch(req, res) { - const collection = req.collection - if (!req.body.books || !req.body.books.length) { + // filter out invalid libraryItemIds + const bookIdsToAdd = (req.body.books || []).filter(b => !!b && typeof b == 'string') + if (!bookIdsToAdd.length) { return res.status(500).send('Invalid request body') } - const bookIdsToAdd = req.body.books + + // Get library items associated with ids + const libraryItems = await Database.models.libraryItem.findAll({ + where: { + id: { + [Sequelize.Op.in]: bookIdsToAdd + } + }, + include: { + model: Database.models.book + } + }) + + // Get collection books already in collection + const collectionBooks = await req.collection.getCollectionBooks() + + let order = collectionBooks.length + 1 const collectionBooksToAdd = [] let hasUpdated = false - let order = collection.books.length - for (const libraryItemId of bookIdsToAdd) { - const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId) - if (!libraryItem) continue - if (!collection.books.includes(libraryItemId)) { - collection.addBook(libraryItemId) + // Check and set new collection books to add + for (const libraryItem of libraryItems) { + if (!collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) { collectionBooksToAdd.push({ - collectionId: collection.id, + collectionId: req.collection.id, bookId: libraryItem.media.id, order: order++ }) hasUpdated = true + } else { + Logger.warn(`[CollectionController] addBatch: Library item ${libraryItem.id} already in collection`) } } + let jsonExpanded = null if (hasUpdated) { await Database.createBulkCollectionBooks(collectionBooksToAdd) - SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems)) + jsonExpanded = await req.collection.getOldJsonExpanded() + SocketAuthority.emitter('collection_updated', jsonExpanded) + } else { + jsonExpanded = await req.collection.getOldJsonExpanded() } - res.json(collection.toJSONExpanded(Database.libraryItems)) + res.json(jsonExpanded) } - // POST: api/collections/:id/batch/remove + /** + * POST: /api/collections/:id/batch/remove + * Remove multiple books from collection + * Req.body { books: } + * @param {*} req + * @param {*} res + */ async removeBatch(req, res) { - const collection = req.collection - if (!req.body.books || !req.body.books.length) { + // filter out invalid libraryItemIds + const bookIdsToRemove = (req.body.books || []).filter(b => !!b && typeof b == 'string') + if (!bookIdsToRemove.length) { return res.status(500).send('Invalid request body') } - var bookIdsToRemove = req.body.books - let hasUpdated = false - for (const libraryItemId of bookIdsToRemove) { - const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId) - if (!libraryItem) continue - if (collection.books.includes(libraryItemId)) { - collection.removeBook(libraryItemId) + // Get library items associated with ids + const libraryItems = await Database.models.libraryItem.findAll({ + where: { + id: { + [Sequelize.Op.in]: bookIdsToRemove + } + }, + include: { + model: Database.models.book + } + }) + + // Get collection books already in collection + const collectionBooks = await req.collection.getCollectionBooks({ + order: [['order', 'ASC']] + }) + + // Remove collection books and update order + let order = 1 + let hasUpdated = false + for (const collectionBook of collectionBooks) { + if (libraryItems.some(li => li.media.id === collectionBook.bookId)) { + await collectionBook.destroy() + hasUpdated = true + continue + } else if (collectionBook.order !== order) { + await collectionBook.update({ + order + }) hasUpdated = true } + order++ } + + let jsonExpanded = await req.collection.getOldJsonExpanded() if (hasUpdated) { - await Database.updateCollection(collection) - SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems)) + SocketAuthority.emitter('collection_updated', jsonExpanded) } - res.json(collection.toJSONExpanded(Database.libraryItems)) + res.json(jsonExpanded) } async middleware(req, res, next) { if (req.params.id) { - const collection = await Database.models.collection.getById(req.params.id) + const collection = await Database.models.collection.findByPk(req.params.id) if (!collection) { return res.status(404).send('Collection not found') } diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js index 8c351c78..a7c8a9c3 100644 --- a/server/controllers/PlaylistController.js +++ b/server/controllers/PlaylistController.js @@ -201,7 +201,7 @@ class PlaylistController { // POST: api/playlists/collection/:collectionId async createFromCollection(req, res) { - let collection = await Database.models.collection.getById(req.params.collectionId) + let collection = await Database.models.collection.getOldById(req.params.collectionId) if (!collection) { return res.status(404).send('Collection not found') } diff --git a/server/controllers/RSSFeedController.js b/server/controllers/RSSFeedController.js index 82175e4c..ad356fd5 100644 --- a/server/controllers/RSSFeedController.js +++ b/server/controllers/RSSFeedController.js @@ -45,7 +45,7 @@ class RSSFeedController { async openRSSFeedForCollection(req, res) { const options = req.body || {} - const collection = await Database.models.collection.getById(req.params.collectionId) + const collection = await Database.models.collection.getOldById(req.params.collectionId) if (!collection) return res.sendStatus(404) // Check request body options exist diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 7e4759a2..bf440b46 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -12,7 +12,7 @@ class RssFeedManager { async validateFeedEntity(feedObj) { if (feedObj.entityType === 'collection') { - const collection = await Database.models.collection.getById(feedObj.entityId) + const collection = await Database.models.collection.getOldById(feedObj.entityId) if (!collection) { Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`) return false @@ -102,7 +102,7 @@ class RssFeedManager { await Database.updateFeed(feed) } } else if (feed.entityType === 'collection') { - const collection = await Database.models.collection.getById(feed.entityId) + const collection = await Database.models.collection.getOldById(feed.entityId) if (collection) { const collectionExpanded = collection.toJSONExpanded(Database.libraryItems) diff --git a/server/models/Collection.js b/server/models/Collection.js index ebe6a597..78b7c7c6 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -1,4 +1,4 @@ -const { DataTypes, Model } = require('sequelize') +const { DataTypes, Model, Sequelize } = require('sequelize') const oldCollection = require('../objects/Collection') const { areEquivalent } = require('../utils/index') @@ -112,6 +112,76 @@ module.exports = (sequelize) => { }).filter(c => c) } + /** + * Get old collection toJSONExpanded, items filtered for user permissions + * @param {[oldUser]} user + * @param {[string[]]} include + * @returns {Promise} oldCollection.toJSONExpanded + */ + async getOldJsonExpanded(user, include) { + this.books = await this.getBooks({ + include: [ + { + model: sequelize.models.libraryItem + }, + { + model: sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: sequelize.models.series, + through: { + attributes: ['sequence'] + } + }, + + ], + order: [Sequelize.literal('`collectionBook.order` ASC')] + }) || [] + + const oldCollection = sequelize.models.collection.getOldCollection(this) + + // Filter books using user permissions + // TODO: Handle user permission restrictions on initial query + const books = this.books?.filter(b => { + if (user) { + if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { + return false + } + if (b.explicit === true && !user.canAccessExplicitContent) { + return false + } + } + return true + }) || [] + + // Map to library items + const libraryItems = books.map(b => { + const libraryItem = b.libraryItem + delete b.libraryItem + libraryItem.media = b + return sequelize.models.libraryItem.getOldLibraryItem(libraryItem) + }) + + // Users with restricted permissions will not see this collection + if (!books.length && oldCollection.books.length) { + return null + } + + const collectionExpanded = oldCollection.toJSONExpanded(libraryItems) + + if (include?.includes('rssfeed')) { + const feeds = await this.getFeeds() + if (feeds?.length) { + collectionExpanded.rssFeed = sequelize.models.feed.getOldFeed(feeds[0]) + } + } + + return collectionExpanded + } + /** * Get old collection from Collection * @param {Collection} collectionExpanded @@ -195,11 +265,11 @@ module.exports = (sequelize) => { } /** - * Get collection by id + * Get old collection by id * @param {string} collectionId * @returns {Promise} returns null if not found */ - static async getById(collectionId) { + static async getOldById(collectionId) { if (!collectionId) return null const collection = await this.findByPk(collectionId, { include: { @@ -212,6 +282,36 @@ module.exports = (sequelize) => { return this.getOldCollection(collection) } + /** + * Get old collection from current + * @returns {Promise} + */ + async getOld() { + this.books = await this.getBooks({ + include: [ + { + model: sequelize.models.libraryItem + }, + { + model: sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: sequelize.models.series, + through: { + attributes: ['sequence'] + } + }, + + ], + order: [Sequelize.literal('`collectionBook.order` ASC')] + }) || [] + + return sequelize.models.collection.getOldCollection(this) + } + /** * Remove all collections belonging to library * @param {string} libraryId @@ -226,26 +326,6 @@ module.exports = (sequelize) => { }) } - /** - * Get all collections for a library - * @param {string} libraryId - * @returns {Promise} - */ - static async getAllForLibrary(libraryId) { - if (!libraryId) return [] - const collections = await this.findAll({ - where: { - libraryId - }, - include: { - model: sequelize.models.book, - include: sequelize.models.libraryItem - }, - order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] - }) - return collections.map(c => this.getOldCollection(c)) - } - static async getAllForBook(bookId) { const collections = await this.findAll({ include: { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index fdd3c994..a8dfad13 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -393,14 +393,6 @@ class ApiRouter { // TODO: Remove open sessions for library item let mediaItemIds = [] if (libraryItem.isBook) { - // remove book from collections - const collectionsWithBook = await Database.models.collection.getAllForBook(libraryItem.media.id) - for (const collection of collectionsWithBook) { - collection.removeBook(libraryItem.id) - await Database.removeCollectionBook(collection.id, libraryItem.media.id) - SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems)) - } - // Check remove empty series await this.checkRemoveEmptySeries(libraryItem.media.metadata.series, libraryItem.id) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 07c7f2b5..a0abcc92 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -882,24 +882,25 @@ module.exports = { Logger.error(`[libraryItemsBookFilters] Invalid collection`, collection) return [] } + const books = await Database.models.book.findAll({ - where: { - id: { - [Sequelize.Op.in]: collection.books - } - }, include: [ { - model: Database.models.libraryItem + model: Database.models.libraryItem, + where: { + id: { + [Sequelize.Op.in]: collection.books + } + } }, { - model: sequelize.models.author, + model: Database.models.author, through: { attributes: [] } }, { - model: sequelize.models.series, + model: Database.models.series, through: { attributes: ['sequence'] }