From c707bcf0f6c236c6ab6ef8da089e237a42b9517e Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 15 Aug 2023 18:03:43 -0500 Subject: [PATCH 01/19] Add jsdoc types for models --- server/Database.js | 20 +- server/models/Book.js | 379 ++++++++++--------- server/models/BookAuthor.js | 82 +++-- server/models/BookSeries.js | 87 +++-- server/models/Collection.js | 564 +++++++++++++++-------------- server/models/CollectionBook.js | 91 +++-- server/models/Device.js | 235 ++++++------ server/models/Feed.js | 620 +++++++++++++++++--------------- server/models/FeedEpisode.js | 189 ++++++---- server/models/Library.js | 424 ++++++++++++---------- server/models/LibraryFolder.js | 79 ++-- 11 files changed, 1537 insertions(+), 1233 deletions(-) diff --git a/server/Database.js b/server/Database.js index 7dc4f5aa..4d97574b 100644 --- a/server/Database.js +++ b/server/Database.js @@ -93,25 +93,25 @@ class Database { buildModels(force = false) { require('./models/User')(this.sequelize) - require('./models/Library')(this.sequelize) - require('./models/LibraryFolder')(this.sequelize) - require('./models/Book')(this.sequelize) + require('./models/Library').init(this.sequelize) + require('./models/LibraryFolder').init(this.sequelize) + require('./models/Book').init(this.sequelize) require('./models/Podcast')(this.sequelize) require('./models/PodcastEpisode')(this.sequelize) require('./models/LibraryItem')(this.sequelize) require('./models/MediaProgress')(this.sequelize) require('./models/Series')(this.sequelize) - require('./models/BookSeries')(this.sequelize) + require('./models/BookSeries').init(this.sequelize) require('./models/Author').init(this.sequelize) - require('./models/BookAuthor')(this.sequelize) - require('./models/Collection')(this.sequelize) - require('./models/CollectionBook')(this.sequelize) + require('./models/BookAuthor').init(this.sequelize) + require('./models/Collection').init(this.sequelize) + require('./models/CollectionBook').init(this.sequelize) require('./models/Playlist')(this.sequelize) require('./models/PlaylistMediaItem')(this.sequelize) - require('./models/Device')(this.sequelize) + require('./models/Device').init(this.sequelize) require('./models/PlaybackSession')(this.sequelize) - require('./models/Feed')(this.sequelize) - require('./models/FeedEpisode')(this.sequelize) + require('./models/Feed').init(this.sequelize) + require('./models/FeedEpisode').init(this.sequelize) require('./models/Setting')(this.sequelize) return this.sequelize.sync({ force, alter: false }) diff --git a/server/models/Book.js b/server/models/Book.js index b17afc6d..415064de 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -1,178 +1,231 @@ const { DataTypes, Model } = require('sequelize') const Logger = require('../Logger') -module.exports = (sequelize) => { - class Book extends Model { - static getOldBook(libraryItemExpanded) { - const bookExpanded = libraryItemExpanded.media - let authors = [] - if (bookExpanded.authors?.length) { - authors = bookExpanded.authors.map(au => { - return { - id: au.id, - name: au.name - } - }) - } else if (bookExpanded.bookAuthors?.length) { - authors = bookExpanded.bookAuthors.map(ba => { - if (ba.author) { - return { - id: ba.author.id, - name: ba.author.name - } - } else { - Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba) - return null - } - }).filter(a => a) - } +class Book extends Model { + constructor(values, options) { + super(values, options) - let series = [] - if (bookExpanded.series?.length) { - series = bookExpanded.series.map(se => { - return { - id: se.id, - name: se.name, - sequence: se.bookSeries.sequence - } - }) - } else if (bookExpanded.bookSeries?.length) { - series = bookExpanded.bookSeries.map(bs => { - if (bs.series) { - return { - id: bs.series.id, - name: bs.series.name, - sequence: bs.sequence - } - } else { - Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs) - return null - } - }).filter(s => s) - } + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.title + /** @type {string} */ + this.titleIgnorePrefix + /** @type {string} */ + this.publishedYear + /** @type {string} */ + this.publishedDate + /** @type {string} */ + this.publisher + /** @type {string} */ + this.description + /** @type {string} */ + this.isbn + /** @type {string} */ + this.asin + /** @type {string} */ + this.language + /** @type {boolean} */ + this.explicit + /** @type {boolean} */ + this.abridged + /** @type {string} */ + this.coverPath + /** @type {number} */ + this.duration + /** @type {Object} */ + this.narrators + /** @type {Object} */ + this.audioFiles + /** @type {Object} */ + this.ebookFile + /** @type {Object} */ + this.chapters + /** @type {Object} */ + this.tags + /** @type {Object} */ + this.genres + /** @type {Date} */ + this.updatedAt + /** @type {Date} */ + this.createdAt + } - return { - id: bookExpanded.id, - libraryItemId: libraryItemExpanded.id, - coverPath: bookExpanded.coverPath, - tags: bookExpanded.tags, - audioFiles: bookExpanded.audioFiles, - chapters: bookExpanded.chapters, - ebookFile: bookExpanded.ebookFile, - metadata: { - title: bookExpanded.title, - subtitle: bookExpanded.subtitle, - authors: authors, - narrators: bookExpanded.narrators, - series: series, - genres: bookExpanded.genres, - publishedYear: bookExpanded.publishedYear, - publishedDate: bookExpanded.publishedDate, - publisher: bookExpanded.publisher, - description: bookExpanded.description, - isbn: bookExpanded.isbn, - asin: bookExpanded.asin, - language: bookExpanded.language, - explicit: bookExpanded.explicit, - abridged: bookExpanded.abridged + static getOldBook(libraryItemExpanded) { + const bookExpanded = libraryItemExpanded.media + let authors = [] + if (bookExpanded.authors?.length) { + authors = bookExpanded.authors.map(au => { + return { + id: au.id, + name: au.name } - } - } - - /** - * @param {object} oldBook - * @returns {boolean} true if updated - */ - static saveFromOld(oldBook) { - const book = this.getFromOld(oldBook) - return this.update(book, { - where: { - id: book.id - } - }).then(result => result[0] > 0).catch((error) => { - Logger.error(`[Book] Failed to save book ${book.id}`, error) - return false }) + } else if (bookExpanded.bookAuthors?.length) { + authors = bookExpanded.bookAuthors.map(ba => { + if (ba.author) { + return { + id: ba.author.id, + name: ba.author.name + } + } else { + Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba) + return null + } + }).filter(a => a) } - static getFromOld(oldBook) { - return { - id: oldBook.id, - title: oldBook.metadata.title, - titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix, - subtitle: oldBook.metadata.subtitle, - publishedYear: oldBook.metadata.publishedYear, - publishedDate: oldBook.metadata.publishedDate, - publisher: oldBook.metadata.publisher, - description: oldBook.metadata.description, - isbn: oldBook.metadata.isbn, - asin: oldBook.metadata.asin, - language: oldBook.metadata.language, - explicit: !!oldBook.metadata.explicit, - abridged: !!oldBook.metadata.abridged, - narrators: oldBook.metadata.narrators, - ebookFile: oldBook.ebookFile?.toJSON() || null, - coverPath: oldBook.coverPath, - duration: oldBook.duration, - audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [], - chapters: oldBook.chapters, - tags: oldBook.tags, - genres: oldBook.metadata.genres + let series = [] + if (bookExpanded.series?.length) { + series = bookExpanded.series.map(se => { + return { + id: se.id, + name: se.name, + sequence: se.bookSeries.sequence + } + }) + } else if (bookExpanded.bookSeries?.length) { + series = bookExpanded.bookSeries.map(bs => { + if (bs.series) { + return { + id: bs.series.id, + name: bs.series.name, + sequence: bs.sequence + } + } else { + Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs) + return null + } + }).filter(s => s) + } + + return { + id: bookExpanded.id, + libraryItemId: libraryItemExpanded.id, + coverPath: bookExpanded.coverPath, + tags: bookExpanded.tags, + audioFiles: bookExpanded.audioFiles, + chapters: bookExpanded.chapters, + ebookFile: bookExpanded.ebookFile, + metadata: { + title: bookExpanded.title, + subtitle: bookExpanded.subtitle, + authors: authors, + narrators: bookExpanded.narrators, + series: series, + genres: bookExpanded.genres, + publishedYear: bookExpanded.publishedYear, + publishedDate: bookExpanded.publishedDate, + publisher: bookExpanded.publisher, + description: bookExpanded.description, + isbn: bookExpanded.isbn, + asin: bookExpanded.asin, + language: bookExpanded.language, + explicit: bookExpanded.explicit, + abridged: bookExpanded.abridged } } } - Book.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - title: DataTypes.STRING, - titleIgnorePrefix: DataTypes.STRING, - subtitle: DataTypes.STRING, - publishedYear: DataTypes.STRING, - publishedDate: DataTypes.STRING, - publisher: DataTypes.STRING, - description: DataTypes.TEXT, - isbn: DataTypes.STRING, - asin: DataTypes.STRING, - language: DataTypes.STRING, - explicit: DataTypes.BOOLEAN, - abridged: DataTypes.BOOLEAN, - coverPath: DataTypes.STRING, - duration: DataTypes.FLOAT, - - narrators: DataTypes.JSON, - audioFiles: DataTypes.JSON, - ebookFile: DataTypes.JSON, - chapters: DataTypes.JSON, - tags: DataTypes.JSON, - genres: DataTypes.JSON - }, { - sequelize, - modelName: 'book', - indexes: [ - { - fields: [{ - name: 'title', - collate: 'NOCASE' - }] - }, - { - fields: [{ - name: 'titleIgnorePrefix', - collate: 'NOCASE' - }] - }, - { - fields: ['publishedYear'] - }, - { - fields: ['duration'] + /** + * @param {object} oldBook + * @returns {boolean} true if updated + */ + static saveFromOld(oldBook) { + const book = this.getFromOld(oldBook) + return this.update(book, { + where: { + id: book.id } - ] - }) + }).then(result => result[0] > 0).catch((error) => { + Logger.error(`[Book] Failed to save book ${book.id}`, error) + return false + }) + } - return Book -} \ No newline at end of file + static getFromOld(oldBook) { + return { + id: oldBook.id, + title: oldBook.metadata.title, + titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix, + subtitle: oldBook.metadata.subtitle, + publishedYear: oldBook.metadata.publishedYear, + publishedDate: oldBook.metadata.publishedDate, + publisher: oldBook.metadata.publisher, + description: oldBook.metadata.description, + isbn: oldBook.metadata.isbn, + asin: oldBook.metadata.asin, + language: oldBook.metadata.language, + explicit: !!oldBook.metadata.explicit, + abridged: !!oldBook.metadata.abridged, + narrators: oldBook.metadata.narrators, + ebookFile: oldBook.ebookFile?.toJSON() || null, + coverPath: oldBook.coverPath, + duration: oldBook.duration, + audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [], + chapters: oldBook.chapters, + tags: oldBook.tags, + genres: oldBook.metadata.genres + } + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: DataTypes.STRING, + titleIgnorePrefix: DataTypes.STRING, + subtitle: DataTypes.STRING, + publishedYear: DataTypes.STRING, + publishedDate: DataTypes.STRING, + publisher: DataTypes.STRING, + description: DataTypes.TEXT, + isbn: DataTypes.STRING, + asin: DataTypes.STRING, + language: DataTypes.STRING, + explicit: DataTypes.BOOLEAN, + abridged: DataTypes.BOOLEAN, + coverPath: DataTypes.STRING, + duration: DataTypes.FLOAT, + + narrators: DataTypes.JSON, + audioFiles: DataTypes.JSON, + ebookFile: DataTypes.JSON, + chapters: DataTypes.JSON, + tags: DataTypes.JSON, + genres: DataTypes.JSON + }, { + sequelize, + modelName: 'book', + indexes: [ + { + fields: [{ + name: 'title', + collate: 'NOCASE' + }] + }, + { + fields: [{ + name: 'titleIgnorePrefix', + collate: 'NOCASE' + }] + }, + { + fields: ['publishedYear'] + }, + { + fields: ['duration'] + } + ] + }) + } +} + +module.exports = Book \ No newline at end of file diff --git a/server/models/BookAuthor.js b/server/models/BookAuthor.js index c425d2fd..9f8860ee 100644 --- a/server/models/BookAuthor.js +++ b/server/models/BookAuthor.js @@ -1,41 +1,57 @@ const { DataTypes, Model } = require('sequelize') -module.exports = (sequelize) => { - class BookAuthor extends Model { - static removeByIds(authorId = null, bookId = null) { - const where = {} - if (authorId) where.authorId = authorId - if (bookId) where.bookId = bookId - return this.destroy({ - where - }) - } +class BookAuthor extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {UUIDV4} */ + this.bookId + /** @type {UUIDV4} */ + this.authorId + /** @type {Date} */ + this.createdAt } - BookAuthor.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - } - }, { - sequelize, - modelName: 'bookAuthor', - timestamps: true, - updatedAt: false - }) + static removeByIds(authorId = null, bookId = null) { + const where = {} + if (authorId) where.authorId = authorId + if (bookId) where.bookId = bookId + return this.destroy({ + where + }) + } - // Super Many-to-Many - // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship - const { book, author } = sequelize.models - book.belongsToMany(author, { through: BookAuthor }) - author.belongsToMany(book, { through: BookAuthor }) + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + } + }, { + sequelize, + modelName: 'bookAuthor', + timestamps: true, + updatedAt: false + }) - book.hasMany(BookAuthor) - BookAuthor.belongsTo(book) + // Super Many-to-Many + // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship + const { book, author } = sequelize.models + book.belongsToMany(author, { through: BookAuthor }) + author.belongsToMany(book, { through: BookAuthor }) - author.hasMany(BookAuthor) - BookAuthor.belongsTo(author) + book.hasMany(BookAuthor) + BookAuthor.belongsTo(book) - return BookAuthor -} \ No newline at end of file + author.hasMany(BookAuthor) + BookAuthor.belongsTo(author) + } +} +module.exports = BookAuthor \ No newline at end of file diff --git a/server/models/BookSeries.js b/server/models/BookSeries.js index ba6581f2..680ad0c1 100644 --- a/server/models/BookSeries.js +++ b/server/models/BookSeries.js @@ -1,42 +1,61 @@ const { DataTypes, Model } = require('sequelize') -module.exports = (sequelize) => { - class BookSeries extends Model { - static removeByIds(seriesId = null, bookId = null) { - const where = {} - if (seriesId) where.seriesId = seriesId - if (bookId) where.bookId = bookId - return this.destroy({ - where - }) - } +class BookSeries extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.sequence + /** @type {UUIDV4} */ + this.bookId + /** @type {UUIDV4} */ + this.seriesId + /** @type {Date} */ + this.createdAt } - BookSeries.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - sequence: DataTypes.STRING - }, { - sequelize, - modelName: 'bookSeries', - timestamps: true, - updatedAt: false - }) + static removeByIds(seriesId = null, bookId = null) { + const where = {} + if (seriesId) where.seriesId = seriesId + if (bookId) where.bookId = bookId + return this.destroy({ + where + }) + } - // Super Many-to-Many - // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship - const { book, series } = sequelize.models - book.belongsToMany(series, { through: BookSeries }) - series.belongsToMany(book, { through: BookSeries }) + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + sequence: DataTypes.STRING + }, { + sequelize, + modelName: 'bookSeries', + timestamps: true, + updatedAt: false + }) - book.hasMany(BookSeries) - BookSeries.belongsTo(book) + // Super Many-to-Many + // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship + const { book, series } = sequelize.models + book.belongsToMany(series, { through: BookSeries }) + series.belongsToMany(book, { through: BookSeries }) - series.hasMany(BookSeries) - BookSeries.belongsTo(series) + book.hasMany(BookSeries) + BookSeries.belongsTo(book) - return BookSeries -} \ No newline at end of file + series.hasMany(BookSeries) + BookSeries.belongsTo(series) + } +} + +module.exports = BookSeries \ No newline at end of file diff --git a/server/models/Collection.js b/server/models/Collection.js index f4aa9b46..9d3a8e0a 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -1,151 +1,97 @@ const { DataTypes, Model, Sequelize } = require('sequelize') const oldCollection = require('../objects/Collection') -const { areEquivalent } = require('../utils/index') -module.exports = (sequelize) => { - class Collection extends Model { - /** - * Get all old collections - * @returns {Promise} - */ - static async getOldCollections() { - const collections = await this.findAll({ - include: { - model: sequelize.models.book, - include: sequelize.models.libraryItem + +class Collection extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.name + /** @type {string} */ + this.description + /** @type {UUIDV4} */ + this.libraryId + /** @type {Date} */ + this.updatedAt + /** @type {Date} */ + this.createdAt + } + /** + * Get all old collections + * @returns {Promise} + */ + static async getOldCollections() { + const collections = await this.findAll({ + include: { + model: this.sequelize.models.book, + include: this.sequelize.models.libraryItem + }, + order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] + }) + return collections.map(c => this.getOldCollection(c)) + } + + /** + * Get all old collections toJSONExpanded, items filtered for user permissions + * @param {[oldUser]} user + * @param {[string]} libraryId + * @param {[string[]]} include + * @returns {Promise} oldCollection.toJSONExpanded + */ + static async getOldCollectionsJsonExpanded(user, libraryId, include) { + let collectionWhere = null + if (libraryId) { + collectionWhere = { + libraryId + } + } + + // Optionally include rssfeed for collection + const collectionIncludes = [] + if (include.includes('rssfeed')) { + collectionIncludes.push({ + model: this.sequelize.models.feed + }) + } + + const collections = await this.findAll({ + where: collectionWhere, + include: [ + { + model: this.sequelize.models.book, + include: [ + { + model: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + }, + + ] }, - order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] - }) - return collections.map(c => this.getOldCollection(c)) - } - - /** - * Get all old collections toJSONExpanded, items filtered for user permissions - * @param {[oldUser]} user - * @param {[string]} libraryId - * @param {[string[]]} include - * @returns {Promise} oldCollection.toJSONExpanded - */ - static async getOldCollectionsJsonExpanded(user, libraryId, include) { - let collectionWhere = null - if (libraryId) { - collectionWhere = { - libraryId - } - } - - // Optionally include rssfeed for collection - const collectionIncludes = [] - if (include.includes('rssfeed')) { - collectionIncludes.push({ - model: sequelize.models.feed - }) - } - - const collections = await this.findAll({ - where: collectionWhere, - include: [ - { - model: sequelize.models.book, - include: [ - { - model: sequelize.models.libraryItem - }, - { - model: sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: sequelize.models.series, - through: { - attributes: ['sequence'] - } - }, - - ] - }, - ...collectionIncludes - ], - order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] - }) - // TODO: Handle user permission restrictions on initial query - return collections.map(c => { - const oldCollection = this.getOldCollection(c) - - // Filter books using user permissions - const books = c.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) - - // Map feed if found - if (c.feeds?.length) { - collectionExpanded.rssFeed = sequelize.models.feed.getOldFeed(c.feeds[0]) - } - - return collectionExpanded - }).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) + ...collectionIncludes + ], + order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] + }) + // TODO: Handle user permission restrictions on initial query + return collections.map(c => { + const oldCollection = this.getOldCollection(c) // Filter books using user permissions - // TODO: Handle user permission restrictions on initial query - const books = this.books?.filter(b => { + const books = c.books?.filter(b => { if (user) { if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { return false @@ -162,7 +108,7 @@ module.exports = (sequelize) => { const libraryItem = b.libraryItem delete b.libraryItem libraryItem.media = b - return sequelize.models.libraryItem.getOldLibraryItem(libraryItem) + return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) }) // Users with restricted permissions will not see this collection @@ -172,151 +118,225 @@ module.exports = (sequelize) => { 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]) - } + // Map feed if found + if (c.feeds?.length) { + collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0]) } return collectionExpanded + }).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: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + }, + + ], + order: [Sequelize.literal('`collectionBook.order` ASC')] + }) || [] + + const oldCollection = this.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 this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) + }) + + // Users with restricted permissions will not see this collection + if (!books.length && oldCollection.books.length) { + return null } - /** - * Get old collection from Collection - * @param {Collection} collectionExpanded - * @returns {oldCollection} - */ - static getOldCollection(collectionExpanded) { - const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || [] - return new oldCollection({ - id: collectionExpanded.id, - libraryId: collectionExpanded.libraryId, - name: collectionExpanded.name, - description: collectionExpanded.description, - books: libraryItemIds, - lastUpdate: collectionExpanded.updatedAt.valueOf(), - createdAt: collectionExpanded.createdAt.valueOf() - }) - } + const collectionExpanded = oldCollection.toJSONExpanded(libraryItems) - static createFromOld(oldCollection) { - const collection = this.getFromOld(oldCollection) - return this.create(collection) - } - - static getFromOld(oldCollection) { - return { - id: oldCollection.id, - name: oldCollection.name, - description: oldCollection.description, - libraryId: oldCollection.libraryId + if (include?.includes('rssfeed')) { + const feeds = await this.getFeeds() + if (feeds?.length) { + collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0]) } } - static removeById(collectionId) { - return this.destroy({ - where: { - id: collectionId - } - }) - } + return collectionExpanded + } - /** - * Get old collection by id - * @param {string} collectionId - * @returns {Promise} returns null if not found - */ - static async getOldById(collectionId) { - if (!collectionId) return null - const collection = await this.findByPk(collectionId, { - include: { - model: sequelize.models.book, - include: sequelize.models.libraryItem - }, - order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] - }) - if (!collection) return null - return this.getOldCollection(collection) - } + /** + * Get old collection from Collection + * @param {Collection} collectionExpanded + * @returns {oldCollection} + */ + static getOldCollection(collectionExpanded) { + const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || [] + return new oldCollection({ + id: collectionExpanded.id, + libraryId: collectionExpanded.libraryId, + name: collectionExpanded.name, + description: collectionExpanded.description, + books: libraryItemIds, + lastUpdate: collectionExpanded.updatedAt.valueOf(), + createdAt: collectionExpanded.createdAt.valueOf() + }) + } - /** - * 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'] - } - }, + static createFromOld(oldCollection) { + const collection = this.getFromOld(oldCollection) + return this.create(collection) + } - ], - order: [Sequelize.literal('`collectionBook.order` ASC')] - }) || [] - - return sequelize.models.collection.getOldCollection(this) - } - - /** - * Remove all collections belonging to library - * @param {string} libraryId - * @returns {Promise} number of collections destroyed - */ - static async removeAllForLibrary(libraryId) { - if (!libraryId) return 0 - return this.destroy({ - where: { - libraryId - } - }) - } - - static async getAllForBook(bookId) { - const collections = await this.findAll({ - include: { - model: sequelize.models.book, - where: { - id: bookId - }, - required: true, - include: sequelize.models.libraryItem - }, - order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] - }) - return collections.map(c => this.getOldCollection(c)) + static getFromOld(oldCollection) { + return { + id: oldCollection.id, + name: oldCollection.name, + description: oldCollection.description, + libraryId: oldCollection.libraryId } } - Collection.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - name: DataTypes.STRING, - description: DataTypes.TEXT - }, { - sequelize, - modelName: 'collection' - }) + static removeById(collectionId) { + return this.destroy({ + where: { + id: collectionId + } + }) + } - const { library } = sequelize.models + /** + * Get old collection by id + * @param {string} collectionId + * @returns {Promise} returns null if not found + */ + static async getOldById(collectionId) { + if (!collectionId) return null + const collection = await this.findByPk(collectionId, { + include: { + model: this.sequelize.models.book, + include: this.sequelize.models.libraryItem + }, + order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] + }) + if (!collection) return null + return this.getOldCollection(collection) + } - library.hasMany(Collection) - Collection.belongsTo(library) + /** + * Get old collection from current + * @returns {Promise} + */ + async getOld() { + this.books = await this.getBooks({ + include: [ + { + model: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + }, - return Collection -} \ No newline at end of file + ], + order: [Sequelize.literal('`collectionBook.order` ASC')] + }) || [] + + return this.sequelize.models.collection.getOldCollection(this) + } + + /** + * Remove all collections belonging to library + * @param {string} libraryId + * @returns {Promise} number of collections destroyed + */ + static async removeAllForLibrary(libraryId) { + if (!libraryId) return 0 + return this.destroy({ + where: { + libraryId + } + }) + } + + static async getAllForBook(bookId) { + const collections = await this.findAll({ + include: { + model: this.sequelize.models.book, + where: { + id: bookId + }, + required: true, + include: this.sequelize.models.libraryItem + }, + order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] + }) + return collections.map(c => this.getOldCollection(c)) + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + description: DataTypes.TEXT + }, { + sequelize, + modelName: 'collection' + }) + + const { library } = sequelize.models + + library.hasMany(Collection) + Collection.belongsTo(library) + } +} + +module.exports = Collection \ No newline at end of file diff --git a/server/models/CollectionBook.js b/server/models/CollectionBook.js index 16ab70c0..aab3a1d3 100644 --- a/server/models/CollectionBook.js +++ b/server/models/CollectionBook.js @@ -1,46 +1,61 @@ const { DataTypes, Model } = require('sequelize') -module.exports = (sequelize) => { - class CollectionBook extends Model { - static removeByIds(collectionId, bookId) { - return this.destroy({ - where: { - bookId, - collectionId - } - }) - } +class CollectionBook extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {number} */ + this.order + /** @type {UUIDV4} */ + this.bookId + /** @type {UUIDV4} */ + this.collectionId + /** @type {Date} */ + this.createdAt } - CollectionBook.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - order: DataTypes.INTEGER - }, { - sequelize, - timestamps: true, - updatedAt: false, - modelName: 'collectionBook' - }) + static removeByIds(collectionId, bookId) { + return this.destroy({ + where: { + bookId, + collectionId + } + }) + } - // Super Many-to-Many - // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship - const { book, collection } = sequelize.models - book.belongsToMany(collection, { through: CollectionBook }) - collection.belongsToMany(book, { through: CollectionBook }) + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + order: DataTypes.INTEGER + }, { + sequelize, + timestamps: true, + updatedAt: false, + modelName: 'collectionBook' + }) - book.hasMany(CollectionBook, { - onDelete: 'CASCADE' - }) - CollectionBook.belongsTo(book) + // Super Many-to-Many + // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship + const { book, collection } = sequelize.models + book.belongsToMany(collection, { through: CollectionBook }) + collection.belongsToMany(book, { through: CollectionBook }) - collection.hasMany(CollectionBook, { - onDelete: 'CASCADE' - }) - CollectionBook.belongsTo(collection) + book.hasMany(CollectionBook, { + onDelete: 'CASCADE' + }) + CollectionBook.belongsTo(book) - return CollectionBook -} \ No newline at end of file + collection.hasMany(CollectionBook, { + onDelete: 'CASCADE' + }) + CollectionBook.belongsTo(collection) + } +} + +module.exports = CollectionBook \ No newline at end of file diff --git a/server/models/Device.js b/server/models/Device.js index a8917c19..24cd2276 100644 --- a/server/models/Device.js +++ b/server/models/Device.js @@ -1,116 +1,147 @@ const { DataTypes, Model } = require('sequelize') const oldDevice = require('../objects/DeviceInfo') -module.exports = (sequelize) => { - class Device extends Model { - getOldDevice() { - let browserVersion = null - let sdkVersion = null - if (this.clientName === 'Abs Android') { - sdkVersion = this.deviceVersion || null - } else { - browserVersion = this.deviceVersion || null - } +class Device extends Model { + constructor(values, options) { + super(values, options) - return new oldDevice({ - id: this.id, - deviceId: this.deviceId, - userId: this.userId, - ipAddress: this.ipAddress, - browserName: this.extraData.browserName || null, - browserVersion, - osName: this.extraData.osName || null, - osVersion: this.extraData.osVersion || null, - clientVersion: this.clientVersion || null, - manufacturer: this.extraData.manufacturer || null, - model: this.extraData.model || null, - sdkVersion, - deviceName: this.deviceName, - clientName: this.clientName - }) + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.deviceId + /** @type {string} */ + this.clientName + /** @type {string} */ + this.clientVersion + /** @type {string} */ + this.ipAddress + /** @type {string} */ + this.deviceName + /** @type {string} */ + this.deviceVersion + /** @type {object} */ + this.extraData + /** @type {UUIDV4} */ + this.userId + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } + + getOldDevice() { + let browserVersion = null + let sdkVersion = null + if (this.clientName === 'Abs Android') { + sdkVersion = this.deviceVersion || null + } else { + browserVersion = this.deviceVersion || null } - static async getOldDeviceByDeviceId(deviceId) { - const device = await this.findOne({ - where: { - deviceId - } - }) - if (!device) return null - return device.getOldDevice() + return new oldDevice({ + id: this.id, + deviceId: this.deviceId, + userId: this.userId, + ipAddress: this.ipAddress, + browserName: this.extraData.browserName || null, + browserVersion, + osName: this.extraData.osName || null, + osVersion: this.extraData.osVersion || null, + clientVersion: this.clientVersion || null, + manufacturer: this.extraData.manufacturer || null, + model: this.extraData.model || null, + sdkVersion, + deviceName: this.deviceName, + clientName: this.clientName + }) + } + + static async getOldDeviceByDeviceId(deviceId) { + const device = await this.findOne({ + where: { + deviceId + } + }) + if (!device) return null + return device.getOldDevice() + } + + static createFromOld(oldDevice) { + const device = this.getFromOld(oldDevice) + return this.create(device) + } + + static updateFromOld(oldDevice) { + const device = this.getFromOld(oldDevice) + return this.update(device, { + where: { + id: device.id + } + }) + } + + static getFromOld(oldDeviceInfo) { + let extraData = {} + + if (oldDeviceInfo.manufacturer) { + extraData.manufacturer = oldDeviceInfo.manufacturer + } + if (oldDeviceInfo.model) { + extraData.model = oldDeviceInfo.model + } + if (oldDeviceInfo.osName) { + extraData.osName = oldDeviceInfo.osName + } + if (oldDeviceInfo.osVersion) { + extraData.osVersion = oldDeviceInfo.osVersion + } + if (oldDeviceInfo.browserName) { + extraData.browserName = oldDeviceInfo.browserName } - static createFromOld(oldDevice) { - const device = this.getFromOld(oldDevice) - return this.create(device) - } - - static updateFromOld(oldDevice) { - const device = this.getFromOld(oldDevice) - return this.update(device, { - where: { - id: device.id - } - }) - } - - static getFromOld(oldDeviceInfo) { - let extraData = {} - - if (oldDeviceInfo.manufacturer) { - extraData.manufacturer = oldDeviceInfo.manufacturer - } - if (oldDeviceInfo.model) { - extraData.model = oldDeviceInfo.model - } - if (oldDeviceInfo.osName) { - extraData.osName = oldDeviceInfo.osName - } - if (oldDeviceInfo.osVersion) { - extraData.osVersion = oldDeviceInfo.osVersion - } - if (oldDeviceInfo.browserName) { - extraData.browserName = oldDeviceInfo.browserName - } - - return { - id: oldDeviceInfo.id, - deviceId: oldDeviceInfo.deviceId, - clientName: oldDeviceInfo.clientName || null, - clientVersion: oldDeviceInfo.clientVersion || null, - ipAddress: oldDeviceInfo.ipAddress, - deviceName: oldDeviceInfo.deviceName || null, - deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null, - userId: oldDeviceInfo.userId, - extraData - } + return { + id: oldDeviceInfo.id, + deviceId: oldDeviceInfo.deviceId, + clientName: oldDeviceInfo.clientName || null, + clientVersion: oldDeviceInfo.clientVersion || null, + ipAddress: oldDeviceInfo.ipAddress, + deviceName: oldDeviceInfo.deviceName || null, + deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null, + userId: oldDeviceInfo.userId, + extraData } } - Device.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - deviceId: DataTypes.STRING, - clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android - clientVersion: DataTypes.STRING, // e.g. Server version or mobile version - ipAddress: DataTypes.STRING, - deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3 - deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'device' - }) + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + deviceId: DataTypes.STRING, + clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android + clientVersion: DataTypes.STRING, // e.g. Server version or mobile version + ipAddress: DataTypes.STRING, + deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3 + deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'device' + }) - const { user } = sequelize.models + const { user } = sequelize.models - user.hasMany(Device, { - onDelete: 'CASCADE' - }) - Device.belongsTo(user) + user.hasMany(Device, { + onDelete: 'CASCADE' + }) + Device.belongsTo(user) + } +} - return Device -} \ No newline at end of file +module.exports = Device \ No newline at end of file diff --git a/server/models/Feed.js b/server/models/Feed.js index 25248b3c..5cf68f7c 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -1,307 +1,361 @@ const { DataTypes, Model } = require('sequelize') const oldFeed = require('../objects/Feed') const areEquivalent = require('../utils/areEquivalent') -/* - * Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ - * Feeds can be created from LibraryItem, Collection, Playlist or Series - */ -module.exports = (sequelize) => { - class Feed extends Model { - static async getOldFeeds() { - const feeds = await this.findAll({ - include: { - model: sequelize.models.feedEpisode - } - }) - return feeds.map(f => this.getOldFeed(f)) - } - /** - * Get old feed from Feed and optionally Feed with FeedEpisodes - * @param {Feed} feedExpanded - * @returns {oldFeed} - */ - static getOldFeed(feedExpanded) { - const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) - return new oldFeed({ - id: feedExpanded.id, - slug: feedExpanded.slug, - userId: feedExpanded.userId, - entityType: feedExpanded.entityType, - entityId: feedExpanded.entityId, - entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null, - coverPath: feedExpanded.coverPath || null, - meta: { - title: feedExpanded.title, - description: feedExpanded.description, - author: feedExpanded.author, - imageUrl: feedExpanded.imageURL, - feedUrl: feedExpanded.feedURL, - link: feedExpanded.siteURL, - explicit: feedExpanded.explicit, - type: feedExpanded.podcastType, - language: feedExpanded.language, - preventIndexing: feedExpanded.preventIndexing, - ownerName: feedExpanded.ownerName, - ownerEmail: feedExpanded.ownerEmail - }, - serverAddress: feedExpanded.serverAddress, +class Feed extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.slug + /** @type {string} */ + this.entityType + /** @type {UUIDV4} */ + this.entityId + /** @type {Date} */ + this.entityUpdatedAt + /** @type {string} */ + this.serverAddress + /** @type {string} */ + this.feedURL + /** @type {string} */ + this.imageURL + /** @type {string} */ + this.siteURL + /** @type {string} */ + this.title + /** @type {string} */ + this.description + /** @type {string} */ + this.author + /** @type {string} */ + this.podcastType + /** @type {string} */ + this.language + /** @type {string} */ + this.ownerName + /** @type {string} */ + this.ownerEmail + /** @type {boolean} */ + this.explicit + /** @type {boolean} */ + this.preventIndexing + /** @type {string} */ + this.coverPath + /** @type {UUIDV4} */ + this.userId + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } + + static async getOldFeeds() { + const feeds = await this.findAll({ + include: { + model: this.sequelize.models.feedEpisode + } + }) + return feeds.map(f => this.getOldFeed(f)) + } + + /** + * Get old feed from Feed and optionally Feed with FeedEpisodes + * @param {Feed} feedExpanded + * @returns {oldFeed} + */ + static getOldFeed(feedExpanded) { + const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) + return new oldFeed({ + id: feedExpanded.id, + slug: feedExpanded.slug, + userId: feedExpanded.userId, + entityType: feedExpanded.entityType, + entityId: feedExpanded.entityId, + entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null, + coverPath: feedExpanded.coverPath || null, + meta: { + title: feedExpanded.title, + description: feedExpanded.description, + author: feedExpanded.author, + imageUrl: feedExpanded.imageURL, feedUrl: feedExpanded.feedURL, - episodes: episodes || [], - createdAt: feedExpanded.createdAt.valueOf(), - updatedAt: feedExpanded.updatedAt.valueOf() - }) - } + link: feedExpanded.siteURL, + explicit: feedExpanded.explicit, + type: feedExpanded.podcastType, + language: feedExpanded.language, + preventIndexing: feedExpanded.preventIndexing, + ownerName: feedExpanded.ownerName, + ownerEmail: feedExpanded.ownerEmail + }, + serverAddress: feedExpanded.serverAddress, + feedUrl: feedExpanded.feedURL, + episodes: episodes || [], + createdAt: feedExpanded.createdAt.valueOf(), + updatedAt: feedExpanded.updatedAt.valueOf() + }) + } - static removeById(feedId) { - return this.destroy({ - where: { - id: feedId - } - }) - } - - /** - * Find all library item ids that have an open feed (used in library filter) - * @returns {Promise>} array of library item ids - */ - static async findAllLibraryItemIds() { - const feeds = await this.findAll({ - attributes: ['entityId'], - where: { - entityType: 'libraryItem' - } - }) - return feeds.map(f => f.entityId).filter(f => f) || [] - } - - /** - * Find feed where and return oldFeed - * @param {object} where sequelize where object - * @returns {Promise} oldFeed - */ - static async findOneOld(where) { - if (!where) return null - const feedExpanded = await this.findOne({ - where, - include: { - model: sequelize.models.feedEpisode - } - }) - if (!feedExpanded) return null - return this.getOldFeed(feedExpanded) - } - - /** - * Find feed and return oldFeed - * @param {string} id - * @returns {Promise} oldFeed - */ - static async findByPkOld(id) { - if (!id) return null - const feedExpanded = await this.findByPk(id, { - include: { - model: sequelize.models.feedEpisode - } - }) - if (!feedExpanded) return null - return this.getOldFeed(feedExpanded) - } - - static async fullCreateFromOld(oldFeed) { - const feedObj = this.getFromOld(oldFeed) - const newFeed = await this.create(feedObj) - - if (oldFeed.episodes?.length) { - for (const oldFeedEpisode of oldFeed.episodes) { - const feedEpisode = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode) - feedEpisode.feedId = newFeed.id - await sequelize.models.feedEpisode.create(feedEpisode) - } + static removeById(feedId) { + return this.destroy({ + where: { + id: feedId } - } + }) + } - static async fullUpdateFromOld(oldFeed) { - const oldFeedEpisodes = oldFeed.episodes || [] - const feedObj = this.getFromOld(oldFeed) - - const existingFeed = await this.findByPk(feedObj.id, { - include: sequelize.models.feedEpisode - }) - if (!existingFeed) return false - - let hasUpdates = false - for (const feedEpisode of existingFeed.feedEpisodes) { - const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id) - // Episode removed - if (!oldFeedEpisode) { - feedEpisode.destroy() - } else { - let episodeHasUpdates = false - const oldFeedEpisodeCleaned = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode) - for (const key in oldFeedEpisodeCleaned) { - if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) { - episodeHasUpdates = true - } - } - if (episodeHasUpdates) { - await feedEpisode.update(oldFeedEpisodeCleaned) - hasUpdates = true - } - } + /** + * Find all library item ids that have an open feed (used in library filter) + * @returns {Promise>} array of library item ids + */ + static async findAllLibraryItemIds() { + const feeds = await this.findAll({ + attributes: ['entityId'], + where: { + entityType: 'libraryItem' } + }) + return feeds.map(f => f.entityId).filter(f => f) || [] + } - let feedHasUpdates = false - for (const key in feedObj) { - let existingValue = existingFeed[key] - if (existingValue instanceof Date) existingValue = existingValue.valueOf() - - if (!areEquivalent(existingValue, feedObj[key])) { - feedHasUpdates = true - } + /** + * Find feed where and return oldFeed + * @param {object} where sequelize where object + * @returns {Promise} oldFeed + */ + static async findOneOld(where) { + if (!where) return null + const feedExpanded = await this.findOne({ + where, + include: { + model: this.sequelize.models.feedEpisode } + }) + if (!feedExpanded) return null + return this.getOldFeed(feedExpanded) + } - if (feedHasUpdates) { - await existingFeed.update(feedObj) - hasUpdates = true + /** + * Find feed and return oldFeed + * @param {string} id + * @returns {Promise} oldFeed + */ + static async findByPkOld(id) { + if (!id) return null + const feedExpanded = await this.findByPk(id, { + include: { + model: this.sequelize.models.feedEpisode } + }) + if (!feedExpanded) return null + return this.getOldFeed(feedExpanded) + } - return hasUpdates - } + static async fullCreateFromOld(oldFeed) { + const feedObj = this.getFromOld(oldFeed) + const newFeed = await this.create(feedObj) - static getFromOld(oldFeed) { - const oldFeedMeta = oldFeed.meta || {} - return { - id: oldFeed.id, - slug: oldFeed.slug, - entityType: oldFeed.entityType, - entityId: oldFeed.entityId, - entityUpdatedAt: oldFeed.entityUpdatedAt, - serverAddress: oldFeed.serverAddress, - feedURL: oldFeed.feedUrl, - coverPath: oldFeed.coverPath || null, - imageURL: oldFeedMeta.imageUrl, - siteURL: oldFeedMeta.link, - title: oldFeedMeta.title, - description: oldFeedMeta.description, - author: oldFeedMeta.author, - podcastType: oldFeedMeta.type || null, - language: oldFeedMeta.language || null, - ownerName: oldFeedMeta.ownerName || null, - ownerEmail: oldFeedMeta.ownerEmail || null, - explicit: !!oldFeedMeta.explicit, - preventIndexing: !!oldFeedMeta.preventIndexing, - userId: oldFeed.userId + if (oldFeed.episodes?.length) { + for (const oldFeedEpisode of oldFeed.episodes) { + const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode) + feedEpisode.feedId = newFeed.id + await this.sequelize.models.feedEpisode.create(feedEpisode) } } - - getEntity(options) { - if (!this.entityType) return Promise.resolve(null) - const mixinMethodName = `get${sequelize.uppercaseFirst(this.entityType)}` - return this[mixinMethodName](options) - } } - Feed.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - slug: DataTypes.STRING, - entityType: DataTypes.STRING, - entityId: DataTypes.UUIDV4, - entityUpdatedAt: DataTypes.DATE, - serverAddress: DataTypes.STRING, - feedURL: DataTypes.STRING, - imageURL: DataTypes.STRING, - siteURL: DataTypes.STRING, - title: DataTypes.STRING, - description: DataTypes.TEXT, - author: DataTypes.STRING, - podcastType: DataTypes.STRING, - language: DataTypes.STRING, - ownerName: DataTypes.STRING, - ownerEmail: DataTypes.STRING, - explicit: DataTypes.BOOLEAN, - preventIndexing: DataTypes.BOOLEAN, - coverPath: DataTypes.STRING - }, { - sequelize, - modelName: 'feed' - }) + static async fullUpdateFromOld(oldFeed) { + const oldFeedEpisodes = oldFeed.episodes || [] + const feedObj = this.getFromOld(oldFeed) - const { user, libraryItem, collection, series, playlist } = sequelize.models + const existingFeed = await this.findByPk(feedObj.id, { + include: this.sequelize.models.feedEpisode + }) + if (!existingFeed) return false - user.hasMany(Feed) - Feed.belongsTo(user) - - libraryItem.hasMany(Feed, { - foreignKey: 'entityId', - constraints: false, - scope: { - entityType: 'libraryItem' - } - }) - Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false }) - - collection.hasMany(Feed, { - foreignKey: 'entityId', - constraints: false, - scope: { - entityType: 'collection' - } - }) - Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false }) - - series.hasMany(Feed, { - foreignKey: 'entityId', - constraints: false, - scope: { - entityType: 'series' - } - }) - Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false }) - - playlist.hasMany(Feed, { - foreignKey: 'entityId', - constraints: false, - scope: { - entityType: 'playlist' - } - }) - Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false }) - - Feed.addHook('afterFind', findResult => { - if (!findResult) return - - if (!Array.isArray(findResult)) findResult = [findResult] - for (const instance of findResult) { - if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) { - instance.entity = instance.libraryItem - instance.dataValues.entity = instance.dataValues.libraryItem - } else if (instance.entityType === 'collection' && instance.collection !== undefined) { - instance.entity = instance.collection - instance.dataValues.entity = instance.dataValues.collection - } else if (instance.entityType === 'series' && instance.series !== undefined) { - instance.entity = instance.series - instance.dataValues.entity = instance.dataValues.series - } else if (instance.entityType === 'playlist' && instance.playlist !== undefined) { - instance.entity = instance.playlist - instance.dataValues.entity = instance.dataValues.playlist + let hasUpdates = false + for (const feedEpisode of existingFeed.feedEpisodes) { + const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id) + // Episode removed + if (!oldFeedEpisode) { + feedEpisode.destroy() + } else { + let episodeHasUpdates = false + const oldFeedEpisodeCleaned = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode) + for (const key in oldFeedEpisodeCleaned) { + if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) { + episodeHasUpdates = true + } + } + if (episodeHasUpdates) { + await feedEpisode.update(oldFeedEpisodeCleaned) + hasUpdates = true + } } - - // To prevent mistakes: - delete instance.libraryItem - delete instance.dataValues.libraryItem - delete instance.collection - delete instance.dataValues.collection - delete instance.series - delete instance.dataValues.series - delete instance.playlist - delete instance.dataValues.playlist } - }) - return Feed -} \ No newline at end of file + let feedHasUpdates = false + for (const key in feedObj) { + let existingValue = existingFeed[key] + if (existingValue instanceof Date) existingValue = existingValue.valueOf() + + if (!areEquivalent(existingValue, feedObj[key])) { + feedHasUpdates = true + } + } + + if (feedHasUpdates) { + await existingFeed.update(feedObj) + hasUpdates = true + } + + return hasUpdates + } + + static getFromOld(oldFeed) { + const oldFeedMeta = oldFeed.meta || {} + return { + id: oldFeed.id, + slug: oldFeed.slug, + entityType: oldFeed.entityType, + entityId: oldFeed.entityId, + entityUpdatedAt: oldFeed.entityUpdatedAt, + serverAddress: oldFeed.serverAddress, + feedURL: oldFeed.feedUrl, + coverPath: oldFeed.coverPath || null, + imageURL: oldFeedMeta.imageUrl, + siteURL: oldFeedMeta.link, + title: oldFeedMeta.title, + description: oldFeedMeta.description, + author: oldFeedMeta.author, + podcastType: oldFeedMeta.type || null, + language: oldFeedMeta.language || null, + ownerName: oldFeedMeta.ownerName || null, + ownerEmail: oldFeedMeta.ownerEmail || null, + explicit: !!oldFeedMeta.explicit, + preventIndexing: !!oldFeedMeta.preventIndexing, + userId: oldFeed.userId + } + } + + getEntity(options) { + if (!this.entityType) return Promise.resolve(null) + const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}` + return this[mixinMethodName](options) + } + + /** + * Initialize model + * + * Polymorphic association: Feeds can be created from LibraryItem, Collection, Playlist or Series + * @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ + * + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + slug: DataTypes.STRING, + entityType: DataTypes.STRING, + entityId: DataTypes.UUIDV4, + entityUpdatedAt: DataTypes.DATE, + serverAddress: DataTypes.STRING, + feedURL: DataTypes.STRING, + imageURL: DataTypes.STRING, + siteURL: DataTypes.STRING, + title: DataTypes.STRING, + description: DataTypes.TEXT, + author: DataTypes.STRING, + podcastType: DataTypes.STRING, + language: DataTypes.STRING, + ownerName: DataTypes.STRING, + ownerEmail: DataTypes.STRING, + explicit: DataTypes.BOOLEAN, + preventIndexing: DataTypes.BOOLEAN, + coverPath: DataTypes.STRING + }, { + sequelize, + modelName: 'feed' + }) + + const { user, libraryItem, collection, series, playlist } = sequelize.models + + user.hasMany(Feed) + Feed.belongsTo(user) + + libraryItem.hasMany(Feed, { + foreignKey: 'entityId', + constraints: false, + scope: { + entityType: 'libraryItem' + } + }) + Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false }) + + collection.hasMany(Feed, { + foreignKey: 'entityId', + constraints: false, + scope: { + entityType: 'collection' + } + }) + Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false }) + + series.hasMany(Feed, { + foreignKey: 'entityId', + constraints: false, + scope: { + entityType: 'series' + } + }) + Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false }) + + playlist.hasMany(Feed, { + foreignKey: 'entityId', + constraints: false, + scope: { + entityType: 'playlist' + } + }) + Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false }) + + Feed.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + for (const instance of findResult) { + if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) { + instance.entity = instance.libraryItem + instance.dataValues.entity = instance.dataValues.libraryItem + } else if (instance.entityType === 'collection' && instance.collection !== undefined) { + instance.entity = instance.collection + instance.dataValues.entity = instance.dataValues.collection + } else if (instance.entityType === 'series' && instance.series !== undefined) { + instance.entity = instance.series + instance.dataValues.entity = instance.dataValues.series + } else if (instance.entityType === 'playlist' && instance.playlist !== undefined) { + instance.entity = instance.playlist + instance.dataValues.entity = instance.dataValues.playlist + } + + // To prevent mistakes: + delete instance.libraryItem + delete instance.dataValues.libraryItem + delete instance.collection + delete instance.dataValues.collection + delete instance.series + delete instance.dataValues.series + delete instance.playlist + delete instance.dataValues.playlist + } + }) + } +} + +module.exports = Feed \ No newline at end of file diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index 2525b664..b126f7a6 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -1,82 +1,125 @@ const { DataTypes, Model } = require('sequelize') -module.exports = (sequelize) => { - class FeedEpisode extends Model { - getOldEpisode() { - const enclosure = { - url: this.enclosureURL, - size: this.enclosureSize, - type: this.enclosureType - } - return { - id: this.id, - title: this.title, - description: this.description, - enclosure, - pubDate: this.pubDate, - link: this.siteURL, - author: this.author, - explicit: this.explicit, - duration: this.duration, - season: this.season, - episode: this.episode, - episodeType: this.episodeType, - fullPath: this.filePath - } - } +class FeedEpisode extends Model { + constructor(values, options) { + super(values, options) - static getFromOld(oldFeedEpisode) { - return { - id: oldFeedEpisode.id, - title: oldFeedEpisode.title, - author: oldFeedEpisode.author, - description: oldFeedEpisode.description, - siteURL: oldFeedEpisode.link, - enclosureURL: oldFeedEpisode.enclosure?.url || null, - enclosureType: oldFeedEpisode.enclosure?.type || null, - enclosureSize: oldFeedEpisode.enclosure?.size || null, - pubDate: oldFeedEpisode.pubDate, - season: oldFeedEpisode.season || null, - episode: oldFeedEpisode.episode || null, - episodeType: oldFeedEpisode.episodeType || null, - duration: oldFeedEpisode.duration, - filePath: oldFeedEpisode.fullPath, - explicit: !!oldFeedEpisode.explicit - } + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.title + /** @type {string} */ + this.description + /** @type {string} */ + this.siteURL + /** @type {string} */ + this.enclosureURL + /** @type {string} */ + this.enclosureType + /** @type {BigInt} */ + this.enclosureSize + /** @type {string} */ + this.pubDate + /** @type {string} */ + this.season + /** @type {string} */ + this.episode + /** @type {string} */ + this.episodeType + /** @type {number} */ + this.duration + /** @type {string} */ + this.filePath + /** @type {boolean} */ + this.explicit + /** @type {UUIDV4} */ + this.feedId + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } + + getOldEpisode() { + const enclosure = { + url: this.enclosureURL, + size: this.enclosureSize, + type: this.enclosureType + } + return { + id: this.id, + title: this.title, + description: this.description, + enclosure, + pubDate: this.pubDate, + link: this.siteURL, + author: this.author, + explicit: this.explicit, + duration: this.duration, + season: this.season, + episode: this.episode, + episodeType: this.episodeType, + fullPath: this.filePath } } - FeedEpisode.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - title: DataTypes.STRING, - author: DataTypes.STRING, - description: DataTypes.TEXT, - siteURL: DataTypes.STRING, - enclosureURL: DataTypes.STRING, - enclosureType: DataTypes.STRING, - enclosureSize: DataTypes.BIGINT, - pubDate: DataTypes.STRING, - season: DataTypes.STRING, - episode: DataTypes.STRING, - episodeType: DataTypes.STRING, - duration: DataTypes.FLOAT, - filePath: DataTypes.STRING, - explicit: DataTypes.BOOLEAN - }, { - sequelize, - modelName: 'feedEpisode' - }) + static getFromOld(oldFeedEpisode) { + return { + id: oldFeedEpisode.id, + title: oldFeedEpisode.title, + author: oldFeedEpisode.author, + description: oldFeedEpisode.description, + siteURL: oldFeedEpisode.link, + enclosureURL: oldFeedEpisode.enclosure?.url || null, + enclosureType: oldFeedEpisode.enclosure?.type || null, + enclosureSize: oldFeedEpisode.enclosure?.size || null, + pubDate: oldFeedEpisode.pubDate, + season: oldFeedEpisode.season || null, + episode: oldFeedEpisode.episode || null, + episodeType: oldFeedEpisode.episodeType || null, + duration: oldFeedEpisode.duration, + filePath: oldFeedEpisode.fullPath, + explicit: !!oldFeedEpisode.explicit + } + } - const { feed } = sequelize.models + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: DataTypes.STRING, + author: DataTypes.STRING, + description: DataTypes.TEXT, + siteURL: DataTypes.STRING, + enclosureURL: DataTypes.STRING, + enclosureType: DataTypes.STRING, + enclosureSize: DataTypes.BIGINT, + pubDate: DataTypes.STRING, + season: DataTypes.STRING, + episode: DataTypes.STRING, + episodeType: DataTypes.STRING, + duration: DataTypes.FLOAT, + filePath: DataTypes.STRING, + explicit: DataTypes.BOOLEAN + }, { + sequelize, + modelName: 'feedEpisode' + }) - feed.hasMany(FeedEpisode, { - onDelete: 'CASCADE' - }) - FeedEpisode.belongsTo(feed) + const { feed } = sequelize.models - return FeedEpisode -} \ No newline at end of file + feed.hasMany(FeedEpisode, { + onDelete: 'CASCADE' + }) + FeedEpisode.belongsTo(feed) + } +} + +module.exports = FeedEpisode \ No newline at end of file diff --git a/server/models/Library.js b/server/models/Library.js index 1fb528cd..9b1d1ace 100644 --- a/server/models/Library.js +++ b/server/models/Library.js @@ -2,217 +2,251 @@ const { DataTypes, Model } = require('sequelize') const Logger = require('../Logger') const oldLibrary = require('../objects/Library') -module.exports = (sequelize) => { - class Library extends Model { - /** - * Get all old libraries - * @returns {Promise} - */ - static async getAllOldLibraries() { - const libraries = await this.findAll({ - include: sequelize.models.libraryFolder, - order: [['displayOrder', 'ASC']] - }) - return libraries.map(lib => this.getOldLibrary(lib)) - } - /** - * Convert expanded Library to oldLibrary - * @param {Library} libraryExpanded - * @returns {Promise} - */ - static getOldLibrary(libraryExpanded) { - const folders = libraryExpanded.libraryFolders.map(folder => { - return { - id: folder.id, - fullPath: folder.path, - libraryId: folder.libraryId, - addedAt: folder.createdAt.valueOf() - } - }) - return new oldLibrary({ - id: libraryExpanded.id, - oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null, - name: libraryExpanded.name, - folders, - displayOrder: libraryExpanded.displayOrder, - icon: libraryExpanded.icon, - mediaType: libraryExpanded.mediaType, - provider: libraryExpanded.provider, - settings: libraryExpanded.settings, - createdAt: libraryExpanded.createdAt.valueOf(), - lastUpdate: libraryExpanded.updatedAt.valueOf() - }) - } +class Library extends Model { + constructor(values, options) { + super(values, options) - /** - * @param {object} oldLibrary - * @returns {Library|null} - */ - static async createFromOld(oldLibrary) { - const library = this.getFromOld(oldLibrary) + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.name + /** @type {number} */ + this.displayOrder + /** @type {string} */ + this.icon + /** @type {string} */ + this.mediaType + /** @type {string} */ + this.provider + /** @type {Date} */ + this.lastScan + /** @type {string} */ + this.lastScanVersion + /** @type {Object} */ + this.settings + /** @type {Object} */ + this.extraData + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } - library.libraryFolders = oldLibrary.folders.map(folder => { - return { - id: folder.id, - path: folder.fullPath - } - }) + /** + * Get all old libraries + * @returns {Promise} + */ + static async getAllOldLibraries() { + const libraries = await this.findAll({ + include: this.sequelize.models.libraryFolder, + order: [['displayOrder', 'ASC']] + }) + return libraries.map(lib => this.getOldLibrary(lib)) + } - return this.create(library, { - include: sequelize.models.libraryFolder - }).catch((error) => { - Logger.error(`[Library] Failed to create library ${library.id}`, error) - return null - }) - } - - /** - * Update library and library folders - * @param {object} oldLibrary - * @returns - */ - static async updateFromOld(oldLibrary) { - const existingLibrary = await this.findByPk(oldLibrary.id, { - include: sequelize.models.libraryFolder - }) - if (!existingLibrary) { - Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`) - return null - } - - const library = this.getFromOld(oldLibrary) - - const libraryFolders = oldLibrary.folders.map(folder => { - return { - id: folder.id, - path: folder.fullPath, - libraryId: library.id - } - }) - for (const libraryFolder of libraryFolders) { - const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id) - if (!existingLibraryFolder) { - await sequelize.models.libraryFolder.create(libraryFolder) - } else if (existingLibraryFolder.path !== libraryFolder.path) { - await existingLibraryFolder.update({ path: libraryFolder.path }) - } - } - - const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id)) - for (const existingLibraryFolder of libraryFoldersRemoved) { - await existingLibraryFolder.destroy() - } - - return existingLibrary.update(library) - } - - static getFromOld(oldLibrary) { - const extraData = {} - if (oldLibrary.oldLibraryId) { - extraData.oldLibraryId = oldLibrary.oldLibraryId - } + /** + * Convert expanded Library to oldLibrary + * @param {Library} libraryExpanded + * @returns {Promise} + */ + static getOldLibrary(libraryExpanded) { + const folders = libraryExpanded.libraryFolders.map(folder => { return { - id: oldLibrary.id, - name: oldLibrary.name, - displayOrder: oldLibrary.displayOrder, - icon: oldLibrary.icon || null, - mediaType: oldLibrary.mediaType || null, - provider: oldLibrary.provider, - settings: oldLibrary.settings?.toJSON() || {}, - createdAt: oldLibrary.createdAt, - updatedAt: oldLibrary.lastUpdate, - extraData + id: folder.id, + fullPath: folder.path, + libraryId: folder.libraryId, + addedAt: folder.createdAt.valueOf() + } + }) + return new oldLibrary({ + id: libraryExpanded.id, + oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null, + name: libraryExpanded.name, + folders, + displayOrder: libraryExpanded.displayOrder, + icon: libraryExpanded.icon, + mediaType: libraryExpanded.mediaType, + provider: libraryExpanded.provider, + settings: libraryExpanded.settings, + createdAt: libraryExpanded.createdAt.valueOf(), + lastUpdate: libraryExpanded.updatedAt.valueOf() + }) + } + + /** + * @param {object} oldLibrary + * @returns {Library|null} + */ + static async createFromOld(oldLibrary) { + const library = this.getFromOld(oldLibrary) + + library.libraryFolders = oldLibrary.folders.map(folder => { + return { + id: folder.id, + path: folder.fullPath + } + }) + + return this.create(library, { + include: this.sequelize.models.libraryFolder + }).catch((error) => { + Logger.error(`[Library] Failed to create library ${library.id}`, error) + return null + }) + } + + /** + * Update library and library folders + * @param {object} oldLibrary + * @returns + */ + static async updateFromOld(oldLibrary) { + const existingLibrary = await this.findByPk(oldLibrary.id, { + include: this.sequelize.models.libraryFolder + }) + if (!existingLibrary) { + Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`) + return null + } + + const library = this.getFromOld(oldLibrary) + + const libraryFolders = oldLibrary.folders.map(folder => { + return { + id: folder.id, + path: folder.fullPath, + libraryId: library.id + } + }) + for (const libraryFolder of libraryFolders) { + const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id) + if (!existingLibraryFolder) { + await this.sequelize.models.libraryFolder.create(libraryFolder) + } else if (existingLibraryFolder.path !== libraryFolder.path) { + await existingLibraryFolder.update({ path: libraryFolder.path }) } } - /** - * Destroy library by id - * @param {string} libraryId - * @returns - */ - static removeById(libraryId) { - return this.destroy({ - where: { - id: libraryId - } - }) + const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id)) + for (const existingLibraryFolder of libraryFoldersRemoved) { + await existingLibraryFolder.destroy() } - /** - * Get all library ids - * @returns {Promise} array of library ids - */ - static async getAllLibraryIds() { - const libraries = await this.findAll({ - attributes: ['id', 'displayOrder'], - order: [['displayOrder', 'ASC']] - }) - return libraries.map(l => l.id) - } + return existingLibrary.update(library) + } - /** - * Find Library by primary key & return oldLibrary - * @param {string} libraryId - * @returns {Promise} Returns null if not found - */ - static async getOldById(libraryId) { - if (!libraryId) return null - const library = await this.findByPk(libraryId, { - include: sequelize.models.libraryFolder - }) - if (!library) return null - return this.getOldLibrary(library) + static getFromOld(oldLibrary) { + const extraData = {} + if (oldLibrary.oldLibraryId) { + extraData.oldLibraryId = oldLibrary.oldLibraryId } - - /** - * Get the largest value in the displayOrder column - * Used for setting a new libraries display order - * @returns {Promise} - */ - static getMaxDisplayOrder() { - return this.max('displayOrder') || 0 + return { + id: oldLibrary.id, + name: oldLibrary.name, + displayOrder: oldLibrary.displayOrder, + icon: oldLibrary.icon || null, + mediaType: oldLibrary.mediaType || null, + provider: oldLibrary.provider, + settings: oldLibrary.settings?.toJSON() || {}, + createdAt: oldLibrary.createdAt, + updatedAt: oldLibrary.lastUpdate, + extraData } + } - /** - * Updates displayOrder to be sequential - * Used after removing a library - */ - static async resetDisplayOrder() { - const libraries = await this.findAll({ - order: [['displayOrder', 'ASC']] - }) - for (let i = 0; i < libraries.length; i++) { - const library = libraries[i] - if (library.displayOrder !== i + 1) { - Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`) - await library.update({ displayOrder: i + 1 }).catch((error) => { - Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error) - }) - } + /** + * Destroy library by id + * @param {string} libraryId + * @returns + */ + static removeById(libraryId) { + return this.destroy({ + where: { + id: libraryId + } + }) + } + + /** + * Get all library ids + * @returns {Promise} array of library ids + */ + static async getAllLibraryIds() { + const libraries = await this.findAll({ + attributes: ['id', 'displayOrder'], + order: [['displayOrder', 'ASC']] + }) + return libraries.map(l => l.id) + } + + /** + * Find Library by primary key & return oldLibrary + * @param {string} libraryId + * @returns {Promise} Returns null if not found + */ + static async getOldById(libraryId) { + if (!libraryId) return null + const library = await this.findByPk(libraryId, { + include: sequelize.models.libraryFolder + }) + if (!library) return null + return this.getOldLibrary(library) + } + + /** + * Get the largest value in the displayOrder column + * Used for setting a new libraries display order + * @returns {Promise} + */ + static getMaxDisplayOrder() { + return this.max('displayOrder') || 0 + } + + /** + * Updates displayOrder to be sequential + * Used after removing a library + */ + static async resetDisplayOrder() { + const libraries = await this.findAll({ + order: [['displayOrder', 'ASC']] + }) + for (let i = 0; i < libraries.length; i++) { + const library = libraries[i] + if (library.displayOrder !== i + 1) { + Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`) + await library.update({ displayOrder: i + 1 }).catch((error) => { + Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error) + }) } } } - Library.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - name: DataTypes.STRING, - displayOrder: DataTypes.INTEGER, - icon: DataTypes.STRING, - mediaType: DataTypes.STRING, - provider: DataTypes.STRING, - lastScan: DataTypes.DATE, - lastScanVersion: DataTypes.STRING, - settings: DataTypes.JSON, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'library' - }) + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + displayOrder: DataTypes.INTEGER, + icon: DataTypes.STRING, + mediaType: DataTypes.STRING, + provider: DataTypes.STRING, + lastScan: DataTypes.DATE, + lastScanVersion: DataTypes.STRING, + settings: DataTypes.JSON, + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'library' + }) + } +} - return Library -} \ No newline at end of file +module.exports = Library \ No newline at end of file diff --git a/server/models/LibraryFolder.js b/server/models/LibraryFolder.js index 1ba240e7..6ae7a8ac 100644 --- a/server/models/LibraryFolder.js +++ b/server/models/LibraryFolder.js @@ -1,36 +1,55 @@ const { DataTypes, Model } = require('sequelize') -module.exports = (sequelize) => { - class LibraryFolder extends Model { - /** - * Gets all library folder path strings - * @returns {Promise} array of library folder paths - */ - static async getAllLibraryFolderPaths() { - const libraryFolders = await this.findAll({ - attributes: ['path'] - }) - return libraryFolders.map(l => l.path) - } +class LibraryFolder extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.path + /** @type {UUIDV4} */ + this.libraryId + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt } - LibraryFolder.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - path: DataTypes.STRING - }, { - sequelize, - modelName: 'libraryFolder' - }) + /** + * Gets all library folder path strings + * @returns {Promise} array of library folder paths + */ + static async getAllLibraryFolderPaths() { + const libraryFolders = await this.findAll({ + attributes: ['path'] + }) + return libraryFolders.map(l => l.path) + } - const { library } = sequelize.models - library.hasMany(LibraryFolder, { - onDelete: 'CASCADE' - }) - LibraryFolder.belongsTo(library) + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + path: DataTypes.STRING + }, { + sequelize, + modelName: 'libraryFolder' + }) - return LibraryFolder -} \ No newline at end of file + const { library } = sequelize.models + library.hasMany(LibraryFolder, { + onDelete: 'CASCADE' + }) + LibraryFolder.belongsTo(library) + } +} + +module.exports = LibraryFolder \ No newline at end of file From 2ae86ab5bbc3a897d1e1e7a88043b7410bba5f4d Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 16 Aug 2023 14:49:06 -0500 Subject: [PATCH 02/19] Fix Library undefined sequelize --- server/models/Library.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/Library.js b/server/models/Library.js index 9b1d1ace..37ece3b5 100644 --- a/server/models/Library.js +++ b/server/models/Library.js @@ -188,7 +188,7 @@ class Library extends Model { static async getOldById(libraryId) { if (!libraryId) return null const library = await this.findByPk(libraryId, { - include: sequelize.models.libraryFolder + include: this.sequelize.models.libraryFolder }) if (!library) return null return this.getOldLibrary(library) From 0bc89cd40f9ecc4a2aa7a1f8f9a2fc9b12263988 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 16 Aug 2023 15:24:56 -0500 Subject: [PATCH 03/19] Fix collapse series and sort by title without ignore prefix --- server/utils/queries/libraryItemsBookFilters.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 67a933b9..7495dce6 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -537,7 +537,7 @@ module.exports = { if (global.ServerSettings.sortingIgnorePrefix) { bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), titleIgnorePrefix)`), 'display_title']) } else { - bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), title)`), 'display_title']) + bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), \`book\`.\`title\`)`), 'display_title']) } } From a98942a361c9ac249560c480314c6303a2133eae Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 16 Aug 2023 16:38:48 -0500 Subject: [PATCH 04/19] Add jsdoc types to remaining models --- server/Database.js | 20 +- server/models/LibraryItem.js | 1540 ++++++++++++++-------------- server/models/MediaProgress.js | 302 +++--- server/models/PlaybackSession.js | 402 ++++---- server/models/Playlist.js | 615 +++++------ server/models/PlaylistMediaItem.js | 173 ++-- server/models/Podcast.js | 239 +++-- server/models/PodcastEpisode.js | 231 +++-- server/models/Series.js | 157 +-- server/models/Setting.js | 81 +- server/models/User.js | 481 +++++---- 11 files changed, 2302 insertions(+), 1939 deletions(-) diff --git a/server/Database.js b/server/Database.js index 4d97574b..ebe6d4c7 100644 --- a/server/Database.js +++ b/server/Database.js @@ -92,27 +92,27 @@ class Database { } buildModels(force = false) { - require('./models/User')(this.sequelize) + require('./models/User').init(this.sequelize) require('./models/Library').init(this.sequelize) require('./models/LibraryFolder').init(this.sequelize) require('./models/Book').init(this.sequelize) - require('./models/Podcast')(this.sequelize) - require('./models/PodcastEpisode')(this.sequelize) - require('./models/LibraryItem')(this.sequelize) - require('./models/MediaProgress')(this.sequelize) - require('./models/Series')(this.sequelize) + require('./models/Podcast').init(this.sequelize) + require('./models/PodcastEpisode').init(this.sequelize) + require('./models/LibraryItem').init(this.sequelize) + require('./models/MediaProgress').init(this.sequelize) + require('./models/Series').init(this.sequelize) require('./models/BookSeries').init(this.sequelize) require('./models/Author').init(this.sequelize) require('./models/BookAuthor').init(this.sequelize) require('./models/Collection').init(this.sequelize) require('./models/CollectionBook').init(this.sequelize) - require('./models/Playlist')(this.sequelize) - require('./models/PlaylistMediaItem')(this.sequelize) + require('./models/Playlist').init(this.sequelize) + require('./models/PlaylistMediaItem').init(this.sequelize) require('./models/Device').init(this.sequelize) - require('./models/PlaybackSession')(this.sequelize) + require('./models/PlaybackSession').init(this.sequelize) require('./models/Feed').init(this.sequelize) require('./models/FeedEpisode').init(this.sequelize) - require('./models/Setting')(this.sequelize) + require('./models/Setting').init(this.sequelize) return this.sequelize.sync({ force, alter: false }) } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index ce56590b..f91d7dd3 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -4,793 +4,845 @@ const oldLibraryItem = require('../objects/LibraryItem') const libraryFilters = require('../utils/queries/libraryFilters') const { areEquivalent } = require('../utils/index') -module.exports = (sequelize) => { - class LibraryItem extends Model { - /** - * Loads all podcast episodes, all library items in chunks of 500, then maps them to old library items - * @todo this is a temporary solution until we can use the sqlite without loading all the library items on init - * - * @returns {Promise} old library items - */ - static async loadAllLibraryItems() { - let start = Date.now() - Logger.info(`[LibraryItem] Loading podcast episodes...`) - const podcastEpisodes = await sequelize.models.podcastEpisode.findAll() - Logger.info(`[LibraryItem] Finished loading ${podcastEpisodes.length} podcast episodes in ${((Date.now() - start) / 1000).toFixed(2)}s`) - start = Date.now() - Logger.info(`[LibraryItem] Loading library items...`) - let libraryItems = await this.getAllOldLibraryItemsIncremental() - Logger.info(`[LibraryItem] Finished loading ${libraryItems.length} library items in ${((Date.now() - start) / 1000).toFixed(2)}s`) +class LibraryItem extends Model { + constructor(values, options) { + super(values, options) - // Map LibraryItem to old library item - libraryItems = libraryItems.map(li => { - if (li.mediaType === 'podcast') { - li.media.podcastEpisodes = podcastEpisodes.filter(pe => pe.podcastId === li.media.id) - } - return this.getOldLibraryItem(li) - }) + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.ino + /** @type {string} */ + this.path + /** @type {string} */ + this.relPath + /** @type {UUIDV4} */ + this.mediaId + /** @type {string} */ + this.mediaType + /** @type {boolean} */ + this.isFile + /** @type {boolean} */ + this.isMissing + /** @type {boolean} */ + this.isInvalid + /** @type {Date} */ + this.mtime + /** @type {Date} */ + this.ctime + /** @type {Date} */ + this.birthtime + /** @type {BigInt} */ + this.size + /** @type {Date} */ + this.lastScan + /** @type {string} */ + this.lastScanVersion + /** @type {Object} */ + this.libraryFiles + /** @type {Object} */ + this.extraData + /** @type {UUIDV4} */ + this.libraryId + /** @type {UUIDV4} */ + this.libraryFolderId + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } + /** + * Loads all podcast episodes, all library items in chunks of 500, then maps them to old library items + * @todo this is a temporary solution until we can use the sqlite without loading all the library items on init + * + * @returns {Promise} old library items + */ + static async loadAllLibraryItems() { + let start = Date.now() + Logger.info(`[LibraryItem] Loading podcast episodes...`) + const podcastEpisodes = await this.sequelize.models.podcastEpisode.findAll() + Logger.info(`[LibraryItem] Finished loading ${podcastEpisodes.length} podcast episodes in ${((Date.now() - start) / 1000).toFixed(2)}s`) + + start = Date.now() + Logger.info(`[LibraryItem] Loading library items...`) + let libraryItems = await this.getAllOldLibraryItemsIncremental() + Logger.info(`[LibraryItem] Finished loading ${libraryItems.length} library items in ${((Date.now() - start) / 1000).toFixed(2)}s`) + + // Map LibraryItem to old library item + libraryItems = libraryItems.map(li => { + if (li.mediaType === 'podcast') { + li.media.podcastEpisodes = podcastEpisodes.filter(pe => pe.podcastId === li.media.id) + } + return this.getOldLibraryItem(li) + }) + + return libraryItems + } + + /** + * Loads all LibraryItem in batches of 500 + * @todo temporary solution + * + * @param {Model[]} libraryItems + * @param {number} offset + * @returns {Promise[]>} + */ + static async getAllOldLibraryItemsIncremental(libraryItems = [], offset = 0) { + const limit = 500 + const rows = await this.getLibraryItemsIncrement(offset, limit) + libraryItems.push(...rows) + if (!rows.length || rows.length < limit) { return libraryItems } + Logger.info(`[LibraryItem] Loaded ${rows.length} library items. ${libraryItems.length} loaded so far.`) + return this.getAllOldLibraryItemsIncremental(libraryItems, offset + rows.length) + } - /** - * Loads all LibraryItem in batches of 500 - * @todo temporary solution - * - * @param {Model[]} libraryItems - * @param {number} offset - * @returns {Promise[]>} - */ - static async getAllOldLibraryItemsIncremental(libraryItems = [], offset = 0) { - const limit = 500 - const rows = await this.getLibraryItemsIncrement(offset, limit) - libraryItems.push(...rows) - if (!rows.length || rows.length < limit) { - return libraryItems - } - Logger.info(`[LibraryItem] Loaded ${rows.length} library items. ${libraryItems.length} loaded so far.`) - return this.getAllOldLibraryItemsIncremental(libraryItems, offset + rows.length) - } - - /** - * Gets library items partially expanded, not including podcast episodes - * @todo temporary solution - * - * @param {number} offset - * @param {number} limit - * @returns {Promise[]>} LibraryItem - */ - static getLibraryItemsIncrement(offset, limit) { - return this.findAll({ - benchmark: true, - logging: (sql, timeMs) => { - console.log(`[Query] Elapsed ${timeMs}ms.`) + /** + * Gets library items partially expanded, not including podcast episodes + * @todo temporary solution + * + * @param {number} offset + * @param {number} limit + * @returns {Promise[]>} LibraryItem + */ + static getLibraryItemsIncrement(offset, limit) { + return this.findAll({ + benchmark: true, + logging: (sql, timeMs) => { + console.log(`[Query] Elapsed ${timeMs}ms.`) + }, + include: [ + { + model: this.sequelize.models.book, + include: [ + { + model: this.sequelize.models.author, + through: { + attributes: ['createdAt'] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence', 'createdAt'] + } + } + ] }, - include: [ - { - model: sequelize.models.book, - include: [ - { - model: sequelize.models.author, - through: { - attributes: ['createdAt'] - } - }, - { - model: sequelize.models.series, - through: { - attributes: ['sequence', 'createdAt'] - } + { + model: this.sequelize.models.podcast + } + ], + order: [ + ['createdAt', 'ASC'], + // Ensure author & series stay in the same order + [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], + [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] + ], + offset, + limit + }) + } + + /** + * Currently unused because this is too slow and uses too much mem + * @param {[WhereOptions]} where + * @returns {Array} old library items + */ + static async getAllOldLibraryItems(where = null) { + let libraryItems = await this.findAll({ + where, + include: [ + { + model: this.sequelize.models.book, + include: [ + { + model: this.sequelize.models.author, + through: { + attributes: [] } - ] - }, - { - model: sequelize.models.podcast - } - ], - order: [ - ['createdAt', 'ASC'], - // Ensure author & series stay in the same order - [sequelize.models.book, sequelize.models.author, sequelize.models.bookAuthor, 'createdAt', 'ASC'], - [sequelize.models.book, sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] - ], - offset, - limit - }) + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ] + }, + { + model: this.sequelize.models.podcast, + include: [ + { + model: this.sequelize.models.podcastEpisode + } + ] + } + ] + }) + return libraryItems.map(ti => this.getOldLibraryItem(ti)) + } + + /** + * Convert an expanded LibraryItem into an old library item + * + * @param {Model} libraryItemExpanded + * @returns {oldLibraryItem} + */ + static getOldLibraryItem(libraryItemExpanded) { + let media = null + if (libraryItemExpanded.mediaType === 'book') { + media = this.sequelize.models.book.getOldBook(libraryItemExpanded) + } else if (libraryItemExpanded.mediaType === 'podcast') { + media = this.sequelize.models.podcast.getOldPodcast(libraryItemExpanded) } - /** - * Currently unused because this is too slow and uses too much mem - * @param {[WhereOptions]} where - * @returns {Array} old library items - */ - static async getAllOldLibraryItems(where = null) { - let libraryItems = await this.findAll({ - where, - include: [ - { - model: sequelize.models.book, - include: [ - { - model: sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: sequelize.models.series, - through: { - attributes: ['sequence'] - } - } - ] - }, - { - model: sequelize.models.podcast, - include: [ - { - model: sequelize.models.podcastEpisode - } - ] - } - ] - }) - return libraryItems.map(ti => this.getOldLibraryItem(ti)) - } + return new oldLibraryItem({ + id: libraryItemExpanded.id, + ino: libraryItemExpanded.ino, + oldLibraryItemId: libraryItemExpanded.extraData?.oldLibraryItemId || null, + libraryId: libraryItemExpanded.libraryId, + folderId: libraryItemExpanded.libraryFolderId, + path: libraryItemExpanded.path, + relPath: libraryItemExpanded.relPath, + isFile: libraryItemExpanded.isFile, + mtimeMs: libraryItemExpanded.mtime?.valueOf(), + ctimeMs: libraryItemExpanded.ctime?.valueOf(), + birthtimeMs: libraryItemExpanded.birthtime?.valueOf(), + addedAt: libraryItemExpanded.createdAt.valueOf(), + updatedAt: libraryItemExpanded.updatedAt.valueOf(), + lastScan: libraryItemExpanded.lastScan?.valueOf(), + scanVersion: libraryItemExpanded.lastScanVersion, + isMissing: !!libraryItemExpanded.isMissing, + isInvalid: !!libraryItemExpanded.isInvalid, + mediaType: libraryItemExpanded.mediaType, + media, + libraryFiles: libraryItemExpanded.libraryFiles + }) + } - /** - * Convert an expanded LibraryItem into an old library item - * - * @param {Model} libraryItemExpanded - * @returns {oldLibraryItem} - */ - static getOldLibraryItem(libraryItemExpanded) { - let media = null - if (libraryItemExpanded.mediaType === 'book') { - media = sequelize.models.book.getOldBook(libraryItemExpanded) - } else if (libraryItemExpanded.mediaType === 'podcast') { - media = sequelize.models.podcast.getOldPodcast(libraryItemExpanded) + static async fullCreateFromOld(oldLibraryItem) { + const newLibraryItem = await this.create(this.getFromOld(oldLibraryItem)) + + if (oldLibraryItem.mediaType === 'book') { + const bookObj = this.sequelize.models.book.getFromOld(oldLibraryItem.media) + bookObj.libraryItemId = newLibraryItem.id + const newBook = await this.sequelize.models.book.create(bookObj) + + const oldBookAuthors = oldLibraryItem.media.metadata.authors || [] + const oldBookSeriesAll = oldLibraryItem.media.metadata.series || [] + + for (const oldBookAuthor of oldBookAuthors) { + await this.sequelize.models.bookAuthor.create({ authorId: oldBookAuthor.id, bookId: newBook.id }) } + for (const oldSeries of oldBookSeriesAll) { + await this.sequelize.models.bookSeries.create({ seriesId: oldSeries.id, bookId: newBook.id, sequence: oldSeries.sequence }) + } + } else if (oldLibraryItem.mediaType === 'podcast') { + const podcastObj = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media) + podcastObj.libraryItemId = newLibraryItem.id + const newPodcast = await this.sequelize.models.podcast.create(podcastObj) - return new oldLibraryItem({ - id: libraryItemExpanded.id, - ino: libraryItemExpanded.ino, - oldLibraryItemId: libraryItemExpanded.extraData?.oldLibraryItemId || null, - libraryId: libraryItemExpanded.libraryId, - folderId: libraryItemExpanded.libraryFolderId, - path: libraryItemExpanded.path, - relPath: libraryItemExpanded.relPath, - isFile: libraryItemExpanded.isFile, - mtimeMs: libraryItemExpanded.mtime?.valueOf(), - ctimeMs: libraryItemExpanded.ctime?.valueOf(), - birthtimeMs: libraryItemExpanded.birthtime?.valueOf(), - addedAt: libraryItemExpanded.createdAt.valueOf(), - updatedAt: libraryItemExpanded.updatedAt.valueOf(), - lastScan: libraryItemExpanded.lastScan?.valueOf(), - scanVersion: libraryItemExpanded.lastScanVersion, - isMissing: !!libraryItemExpanded.isMissing, - isInvalid: !!libraryItemExpanded.isInvalid, - mediaType: libraryItemExpanded.mediaType, - media, - libraryFiles: libraryItemExpanded.libraryFiles - }) + const oldEpisodes = oldLibraryItem.media.episodes || [] + for (const oldEpisode of oldEpisodes) { + const episodeObj = this.sequelize.models.podcastEpisode.getFromOld(oldEpisode) + episodeObj.libraryItemId = newLibraryItem.id + episodeObj.podcastId = newPodcast.id + await this.sequelize.models.podcastEpisode.create(episodeObj) + } } - static async fullCreateFromOld(oldLibraryItem) { - const newLibraryItem = await this.create(this.getFromOld(oldLibraryItem)) + return newLibraryItem + } - if (oldLibraryItem.mediaType === 'book') { - const bookObj = sequelize.models.book.getFromOld(oldLibraryItem.media) - bookObj.libraryItemId = newLibraryItem.id - const newBook = await sequelize.models.book.create(bookObj) - - const oldBookAuthors = oldLibraryItem.media.metadata.authors || [] - const oldBookSeriesAll = oldLibraryItem.media.metadata.series || [] - - for (const oldBookAuthor of oldBookAuthors) { - await sequelize.models.bookAuthor.create({ authorId: oldBookAuthor.id, bookId: newBook.id }) + static async fullUpdateFromOld(oldLibraryItem) { + const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, { + include: [ + { + model: this.sequelize.models.book, + include: [ + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['id', 'sequence'] + } + } + ] + }, + { + model: this.sequelize.models.podcast, + include: [ + { + model: this.sequelize.models.podcastEpisode + } + ] } - for (const oldSeries of oldBookSeriesAll) { - await sequelize.models.bookSeries.create({ seriesId: oldSeries.id, bookId: newBook.id, sequence: oldSeries.sequence }) - } - } else if (oldLibraryItem.mediaType === 'podcast') { - const podcastObj = sequelize.models.podcast.getFromOld(oldLibraryItem.media) - podcastObj.libraryItemId = newLibraryItem.id - const newPodcast = await sequelize.models.podcast.create(podcastObj) + ] + }) + if (!libraryItemExpanded) return false - const oldEpisodes = oldLibraryItem.media.episodes || [] - for (const oldEpisode of oldEpisodes) { - const episodeObj = sequelize.models.podcastEpisode.getFromOld(oldEpisode) - episodeObj.libraryItemId = newLibraryItem.id - episodeObj.podcastId = newPodcast.id - await sequelize.models.podcastEpisode.create(episodeObj) + let hasUpdates = false + + // Check update Book/Podcast + if (libraryItemExpanded.media) { + let updatedMedia = null + if (libraryItemExpanded.mediaType === 'podcast') { + updatedMedia = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media) + + const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || [] + const updatedPodcastEpisodes = oldLibraryItem.media.episodes || [] + + for (const existingPodcastEpisode of existingPodcastEpisodes) { + // Episode was removed + if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) { + Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) + await existingPodcastEpisode.destroy() + hasUpdates = true + } + } + for (const updatedPodcastEpisode of updatedPodcastEpisodes) { + const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id) + if (!existingEpisodeMatch) { + Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) + await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) + hasUpdates = true + } else { + const updatedEpisodeCleaned = this.sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode) + let episodeHasUpdates = false + for (const key in updatedEpisodeCleaned) { + let existingValue = existingEpisodeMatch[key] + if (existingValue instanceof Date) existingValue = existingValue.valueOf() + + if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) { + Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`) + episodeHasUpdates = true + } + } + if (episodeHasUpdates) { + await existingEpisodeMatch.update(updatedEpisodeCleaned) + hasUpdates = true + } + } + } + } else if (libraryItemExpanded.mediaType === 'book') { + updatedMedia = this.sequelize.models.book.getFromOld(oldLibraryItem.media) + + const existingAuthors = libraryItemExpanded.media.authors || [] + const existingSeriesAll = libraryItemExpanded.media.series || [] + const updatedAuthors = oldLibraryItem.media.metadata.authors || [] + const updatedSeriesAll = oldLibraryItem.media.metadata.series || [] + + for (const existingAuthor of existingAuthors) { + // Author was removed from Book + if (!updatedAuthors.some(au => au.id === existingAuthor.id)) { + Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) + await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) + hasUpdates = true + } + } + for (const updatedAuthor of updatedAuthors) { + // Author was added + if (!existingAuthors.some(au => au.id === updatedAuthor.id)) { + Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) + await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id }) + hasUpdates = true + } + } + for (const existingSeries of existingSeriesAll) { + // Series was removed + if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) { + Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) + await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) + hasUpdates = true + } + } + for (const updatedSeries of updatedSeriesAll) { + // Series was added/updated + const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id) + if (!existingSeriesMatch) { + Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) + await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence }) + hasUpdates = true + } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) { + Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) + await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence }) + hasUpdates = true + } } } - return newLibraryItem - } - - static async fullUpdateFromOld(oldLibraryItem) { - const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, { - include: [ - { - model: sequelize.models.book, - include: [ - { - model: sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: sequelize.models.series, - through: { - attributes: ['id', 'sequence'] - } - } - ] - }, - { - model: sequelize.models.podcast, - include: [ - { - model: sequelize.models.podcastEpisode - } - ] - } - ] - }) - if (!libraryItemExpanded) return false - - let hasUpdates = false - - // Check update Book/Podcast - if (libraryItemExpanded.media) { - let updatedMedia = null - if (libraryItemExpanded.mediaType === 'podcast') { - updatedMedia = sequelize.models.podcast.getFromOld(oldLibraryItem.media) - - const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || [] - const updatedPodcastEpisodes = oldLibraryItem.media.episodes || [] - - for (const existingPodcastEpisode of existingPodcastEpisodes) { - // Episode was removed - if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) - await existingPodcastEpisode.destroy() - hasUpdates = true - } - } - for (const updatedPodcastEpisode of updatedPodcastEpisodes) { - const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id) - if (!existingEpisodeMatch) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) - await sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) - hasUpdates = true - } else { - const updatedEpisodeCleaned = sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode) - let episodeHasUpdates = false - for (const key in updatedEpisodeCleaned) { - let existingValue = existingEpisodeMatch[key] - if (existingValue instanceof Date) existingValue = existingValue.valueOf() - - if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`) - episodeHasUpdates = true - } - } - if (episodeHasUpdates) { - await existingEpisodeMatch.update(updatedEpisodeCleaned) - hasUpdates = true - } - } - } - } else if (libraryItemExpanded.mediaType === 'book') { - updatedMedia = sequelize.models.book.getFromOld(oldLibraryItem.media) - - const existingAuthors = libraryItemExpanded.media.authors || [] - const existingSeriesAll = libraryItemExpanded.media.series || [] - const updatedAuthors = oldLibraryItem.media.metadata.authors || [] - const updatedSeriesAll = oldLibraryItem.media.metadata.series || [] - - for (const existingAuthor of existingAuthors) { - // Author was removed from Book - if (!updatedAuthors.some(au => au.id === existingAuthor.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) - await sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) - hasUpdates = true - } - } - for (const updatedAuthor of updatedAuthors) { - // Author was added - if (!existingAuthors.some(au => au.id === updatedAuthor.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) - await sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id }) - hasUpdates = true - } - } - for (const existingSeries of existingSeriesAll) { - // Series was removed - if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) - await sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) - hasUpdates = true - } - } - for (const updatedSeries of updatedSeriesAll) { - // Series was added/updated - const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id) - if (!existingSeriesMatch) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) - await sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence }) - hasUpdates = true - } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) - await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence }) - hasUpdates = true - } - } - } - - let hasMediaUpdates = false - for (const key in updatedMedia) { - let existingValue = libraryItemExpanded.media[key] - if (existingValue instanceof Date) existingValue = existingValue.valueOf() - - if (!areEquivalent(updatedMedia[key], existingValue, true)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) - hasMediaUpdates = true - } - } - if (hasMediaUpdates && updatedMedia) { - await libraryItemExpanded.media.update(updatedMedia) - hasUpdates = true - } - } - - const updatedLibraryItem = this.getFromOld(oldLibraryItem) - let hasLibraryItemUpdates = false - for (const key in updatedLibraryItem) { - let existingValue = libraryItemExpanded[key] + let hasMediaUpdates = false + for (const key in updatedMedia) { + let existingValue = libraryItemExpanded.media[key] if (existingValue instanceof Date) existingValue = existingValue.valueOf() - if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`) - hasLibraryItemUpdates = true + if (!areEquivalent(updatedMedia[key], existingValue, true)) { + Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) + hasMediaUpdates = true } } - if (hasLibraryItemUpdates) { - await libraryItemExpanded.update(updatedLibraryItem) - Logger.info(`[LibraryItem] Library item "${libraryItemExpanded.id}" updated`) + if (hasMediaUpdates && updatedMedia) { + await libraryItemExpanded.media.update(updatedMedia) hasUpdates = true } - return hasUpdates } - static getFromOld(oldLibraryItem) { - const extraData = {} - if (oldLibraryItem.oldLibraryItemId) { - extraData.oldLibraryItemId = oldLibraryItem.oldLibraryItemId - } - return { - id: oldLibraryItem.id, - ino: oldLibraryItem.ino, - path: oldLibraryItem.path, - relPath: oldLibraryItem.relPath, - mediaId: oldLibraryItem.media.id, - mediaType: oldLibraryItem.mediaType, - isFile: !!oldLibraryItem.isFile, - isMissing: !!oldLibraryItem.isMissing, - isInvalid: !!oldLibraryItem.isInvalid, - mtime: oldLibraryItem.mtimeMs, - ctime: oldLibraryItem.ctimeMs, - birthtime: oldLibraryItem.birthtimeMs, - size: oldLibraryItem.size, - lastScan: oldLibraryItem.lastScan, - lastScanVersion: oldLibraryItem.scanVersion, - libraryId: oldLibraryItem.libraryId, - libraryFolderId: oldLibraryItem.folderId, - libraryFiles: oldLibraryItem.libraryFiles?.map(lf => lf.toJSON()) || [], - extraData + const updatedLibraryItem = this.getFromOld(oldLibraryItem) + let hasLibraryItemUpdates = false + for (const key in updatedLibraryItem) { + let existingValue = libraryItemExpanded[key] + if (existingValue instanceof Date) existingValue = existingValue.valueOf() + + if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) { + Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`) + hasLibraryItemUpdates = true } } - - static removeById(libraryItemId) { - return this.destroy({ - where: { - id: libraryItemId - }, - individualHooks: true - }) + if (hasLibraryItemUpdates) { + await libraryItemExpanded.update(updatedLibraryItem) + Logger.info(`[LibraryItem] Library item "${libraryItemExpanded.id}" updated`) + hasUpdates = true } + return hasUpdates + } - /** - * Get old library item by id - * @param {string} libraryItemId - * @returns {oldLibraryItem} - */ - static async getOldById(libraryItemId) { - if (!libraryItemId) return null - const libraryItem = await this.findByPk(libraryItemId, { - include: [ - { - model: sequelize.models.book, - include: [ - { - model: sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: sequelize.models.series, - through: { - attributes: ['sequence'] - } - } - ] - }, - { - model: sequelize.models.podcast, - include: [ - { - model: sequelize.models.podcastEpisode - } - ] - } - ] - }) - if (!libraryItem) return null - return this.getOldLibraryItem(libraryItem) + static getFromOld(oldLibraryItem) { + const extraData = {} + if (oldLibraryItem.oldLibraryItemId) { + extraData.oldLibraryItemId = oldLibraryItem.oldLibraryItemId } - - /** - * Get library items using filter and sort - * @param {oldLibrary} library - * @param {oldUser} user - * @param {object} options - * @returns {object} { libraryItems:oldLibraryItem[], count:number } - */ - static async getByFilterAndSort(library, user, options) { - let start = Date.now() - const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library, user, options) - Logger.debug(`Loaded ${libraryItems.length} of ${count} items for libary page in ${((Date.now() - start) / 1000).toFixed(2)}s`) - - return { - libraryItems: libraryItems.map(li => { - const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified() - if (li.collapsedSeries) { - oldLibraryItem.collapsedSeries = li.collapsedSeries - } - if (li.series) { - oldLibraryItem.media.metadata.series = li.series - } - if (li.rssFeed) { - oldLibraryItem.rssFeed = sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified() - } - if (li.media.numEpisodes) { - oldLibraryItem.media.numEpisodes = li.media.numEpisodes - } - if (li.size && !oldLibraryItem.media.size) { - oldLibraryItem.media.size = li.size - } - if (li.numEpisodesIncomplete) { - oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete - } - - return oldLibraryItem - }), - count - } - } - - /** - * Get home page data personalized shelves - * @param {oldLibrary} library - * @param {oldUser} user - * @param {string[]} include - * @param {number} limit - * @returns {object[]} array of shelf objects - */ - static async getPersonalizedShelves(library, user, include, limit) { - const fullStart = Date.now() // Used for testing load times - - const shelves = [] - - // "Continue Listening" shelf - const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false) - if (itemsInProgressPayload.items.length) { - const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter(li => li.media.isEBookOnly) - const audioOnlyItemsInProgress = itemsInProgressPayload.items.filter(li => !li.media.isEBookOnly) - - shelves.push({ - id: 'continue-listening', - label: 'Continue Listening', - labelStringKey: 'LabelContinueListening', - type: library.isPodcast ? 'episode' : 'book', - entities: audioOnlyItemsInProgress, - total: itemsInProgressPayload.count - }) - - if (ebookOnlyItemsInProgress.length) { - // "Continue Reading" shelf - shelves.push({ - id: 'continue-reading', - label: 'Continue Reading', - labelStringKey: 'LabelContinueReading', - type: 'book', - entities: ebookOnlyItemsInProgress, - total: itemsInProgressPayload.count - }) - } - } - Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) - - let start = Date.now() - if (library.isBook) { - start = Date.now() - // "Continue Series" shelf - const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit) - if (continueSeriesPayload.libraryItems.length) { - shelves.push({ - id: 'continue-series', - label: 'Continue Series', - labelStringKey: 'LabelContinueSeries', - type: 'book', - entities: continueSeriesPayload.libraryItems, - total: continueSeriesPayload.count - }) - } - Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - } else if (library.isPodcast) { - // "Newest Episodes" shelf - const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit) - if (newestEpisodesPayload.libraryItems.length) { - shelves.push({ - id: 'newest-episodes', - label: 'Newest Episodes', - labelStringKey: 'LabelNewestEpisodes', - type: 'episode', - entities: newestEpisodesPayload.libraryItems, - total: newestEpisodesPayload.count - }) - } - Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - } - - start = Date.now() - // "Recently Added" shelf - const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit) - if (mostRecentPayload.libraryItems.length) { - shelves.push({ - id: 'recently-added', - label: 'Recently Added', - labelStringKey: 'LabelRecentlyAdded', - type: library.mediaType, - entities: mostRecentPayload.libraryItems, - total: mostRecentPayload.count - }) - } - Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - - if (library.isBook) { - start = Date.now() - // "Recent Series" shelf - const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5) - if (seriesMostRecentPayload.series.length) { - shelves.push({ - id: 'recent-series', - label: 'Recent Series', - labelStringKey: 'LabelRecentSeries', - type: 'series', - entities: seriesMostRecentPayload.series, - total: seriesMostRecentPayload.count - }) - } - Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - - start = Date.now() - // "Discover" shelf - const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, user, include, limit) - if (discoverLibraryItemsPayload.libraryItems.length) { - shelves.push({ - id: 'discover', - label: 'Discover', - labelStringKey: 'LabelDiscover', - type: library.mediaType, - entities: discoverLibraryItemsPayload.libraryItems, - total: discoverLibraryItemsPayload.count - }) - } - Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - } - - start = Date.now() - // "Listen Again" shelf - const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit) - if (mediaFinishedPayload.items.length) { - const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter(li => li.media.isEBookOnly) - const audioOnlyItemsInProgress = mediaFinishedPayload.items.filter(li => !li.media.isEBookOnly) - - shelves.push({ - id: 'listen-again', - label: 'Listen Again', - labelStringKey: 'LabelListenAgain', - type: library.isPodcast ? 'episode' : 'book', - entities: audioOnlyItemsInProgress, - total: mediaFinishedPayload.count - }) - - // "Read Again" shelf - if (ebookOnlyItemsInProgress.length) { - shelves.push({ - id: 'read-again', - label: 'Read Again', - labelStringKey: 'LabelReadAgain', - type: 'book', - entities: ebookOnlyItemsInProgress, - total: mediaFinishedPayload.count - }) - } - } - Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - - if (library.isBook) { - start = Date.now() - // "Newest Authors" shelf - const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, user, limit) - if (newestAuthorsPayload.authors.length) { - shelves.push({ - id: 'newest-authors', - label: 'Newest Authors', - labelStringKey: 'LabelNewestAuthors', - type: 'authors', - entities: newestAuthorsPayload.authors, - total: newestAuthorsPayload.count - }) - } - Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - } - - Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) - - return shelves - } - - /** - * Get book library items for author, optional use user permissions - * @param {oldAuthor} author - * @param {[oldUser]} user - * @returns {Promise} - */ - static async getForAuthor(author, user = null) { - const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined) - return libraryItems.map(li => this.getOldLibraryItem(li)) - } - - /** - * Get book library items in a collection - * @param {oldCollection} collection - * @returns {Promise} - */ - static async getForCollection(collection) { - const libraryItems = await libraryFilters.getLibraryItemsForCollection(collection) - return libraryItems.map(li => this.getOldLibraryItem(li)) - } - - /** - * Check if library item exists - * @param {string} libraryItemId - * @returns {Promise} - */ - static async checkExistsById(libraryItemId) { - return (await this.count({ where: { id: libraryItemId } })) > 0 - } - - getMedia(options) { - if (!this.mediaType) return Promise.resolve(null) - const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaType)}` - return this[mixinMethodName](options) + return { + id: oldLibraryItem.id, + ino: oldLibraryItem.ino, + path: oldLibraryItem.path, + relPath: oldLibraryItem.relPath, + mediaId: oldLibraryItem.media.id, + mediaType: oldLibraryItem.mediaType, + isFile: !!oldLibraryItem.isFile, + isMissing: !!oldLibraryItem.isMissing, + isInvalid: !!oldLibraryItem.isInvalid, + mtime: oldLibraryItem.mtimeMs, + ctime: oldLibraryItem.ctimeMs, + birthtime: oldLibraryItem.birthtimeMs, + size: oldLibraryItem.size, + lastScan: oldLibraryItem.lastScan, + lastScanVersion: oldLibraryItem.scanVersion, + libraryId: oldLibraryItem.libraryId, + libraryFolderId: oldLibraryItem.folderId, + libraryFiles: oldLibraryItem.libraryFiles?.map(lf => lf.toJSON()) || [], + extraData } } - LibraryItem.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - ino: DataTypes.STRING, - path: DataTypes.STRING, - relPath: DataTypes.STRING, - mediaId: DataTypes.UUIDV4, - mediaType: DataTypes.STRING, - isFile: DataTypes.BOOLEAN, - isMissing: DataTypes.BOOLEAN, - isInvalid: DataTypes.BOOLEAN, - mtime: DataTypes.DATE(6), - ctime: DataTypes.DATE(6), - birthtime: DataTypes.DATE(6), - size: DataTypes.BIGINT, - lastScan: DataTypes.DATE, - lastScanVersion: DataTypes.STRING, - libraryFiles: DataTypes.JSON, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'libraryItem', - indexes: [ - { - fields: ['createdAt'] + static removeById(libraryItemId) { + return this.destroy({ + where: { + id: libraryItemId }, - { - fields: ['mediaId'] - }, - { - fields: ['libraryId', 'mediaType'] - }, - { - fields: ['birthtime'] - }, - { - fields: ['mtime'] + individualHooks: true + }) + } + + /** + * Get old library item by id + * @param {string} libraryItemId + * @returns {oldLibraryItem} + */ + static async getOldById(libraryItemId) { + if (!libraryItemId) return null + const libraryItem = await this.findByPk(libraryItemId, { + include: [ + { + model: this.sequelize.models.book, + include: [ + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ] + }, + { + model: this.sequelize.models.podcast, + include: [ + { + model: this.sequelize.models.podcastEpisode + } + ] + } + ] + }) + if (!libraryItem) return null + return this.getOldLibraryItem(libraryItem) + } + + /** + * Get library items using filter and sort + * @param {oldLibrary} library + * @param {oldUser} user + * @param {object} options + * @returns {object} { libraryItems:oldLibraryItem[], count:number } + */ + static async getByFilterAndSort(library, user, options) { + let start = Date.now() + const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library, user, options) + Logger.debug(`Loaded ${libraryItems.length} of ${count} items for libary page in ${((Date.now() - start) / 1000).toFixed(2)}s`) + + return { + libraryItems: libraryItems.map(li => { + const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified() + if (li.collapsedSeries) { + oldLibraryItem.collapsedSeries = li.collapsedSeries + } + if (li.series) { + oldLibraryItem.media.metadata.series = li.series + } + if (li.rssFeed) { + oldLibraryItem.rssFeed = this.sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified() + } + if (li.media.numEpisodes) { + oldLibraryItem.media.numEpisodes = li.media.numEpisodes + } + if (li.size && !oldLibraryItem.media.size) { + oldLibraryItem.media.size = li.size + } + if (li.numEpisodesIncomplete) { + oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete + } + + return oldLibraryItem + }), + count + } + } + + /** + * Get home page data personalized shelves + * @param {oldLibrary} library + * @param {oldUser} user + * @param {string[]} include + * @param {number} limit + * @returns {object[]} array of shelf objects + */ + static async getPersonalizedShelves(library, user, include, limit) { + const fullStart = Date.now() // Used for testing load times + + const shelves = [] + + // "Continue Listening" shelf + const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false) + if (itemsInProgressPayload.items.length) { + const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter(li => li.media.isEBookOnly) + const audioOnlyItemsInProgress = itemsInProgressPayload.items.filter(li => !li.media.isEBookOnly) + + shelves.push({ + id: 'continue-listening', + label: 'Continue Listening', + labelStringKey: 'LabelContinueListening', + type: library.isPodcast ? 'episode' : 'book', + entities: audioOnlyItemsInProgress, + total: itemsInProgressPayload.count + }) + + if (ebookOnlyItemsInProgress.length) { + // "Continue Reading" shelf + shelves.push({ + id: 'continue-reading', + label: 'Continue Reading', + labelStringKey: 'LabelContinueReading', + type: 'book', + entities: ebookOnlyItemsInProgress, + total: itemsInProgressPayload.count + }) } - ] - }) - - const { library, libraryFolder, book, podcast } = sequelize.models - library.hasMany(LibraryItem) - LibraryItem.belongsTo(library) - - libraryFolder.hasMany(LibraryItem) - LibraryItem.belongsTo(libraryFolder) - - book.hasOne(LibraryItem, { - foreignKey: 'mediaId', - constraints: false, - scope: { - mediaType: 'book' } - }) - LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false }) + Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) - podcast.hasOne(LibraryItem, { - foreignKey: 'mediaId', - constraints: false, - scope: { - mediaType: 'podcast' - } - }) - LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false }) - - LibraryItem.addHook('afterFind', findResult => { - if (!findResult) return - - if (!Array.isArray(findResult)) findResult = [findResult] - for (const instance of findResult) { - if (instance.mediaType === 'book' && instance.book !== undefined) { - instance.media = instance.book - instance.dataValues.media = instance.dataValues.book - } else if (instance.mediaType === 'podcast' && instance.podcast !== undefined) { - instance.media = instance.podcast - instance.dataValues.media = instance.dataValues.podcast + let start = Date.now() + if (library.isBook) { + start = Date.now() + // "Continue Series" shelf + const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit) + if (continueSeriesPayload.libraryItems.length) { + shelves.push({ + id: 'continue-series', + label: 'Continue Series', + labelStringKey: 'LabelContinueSeries', + type: 'book', + entities: continueSeriesPayload.libraryItems, + total: continueSeriesPayload.count + }) } - // To prevent mistakes: - delete instance.book - delete instance.dataValues.book - delete instance.podcast - delete instance.dataValues.podcast + Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + } else if (library.isPodcast) { + // "Newest Episodes" shelf + const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit) + if (newestEpisodesPayload.libraryItems.length) { + shelves.push({ + id: 'newest-episodes', + label: 'Newest Episodes', + labelStringKey: 'LabelNewestEpisodes', + type: 'episode', + entities: newestEpisodesPayload.libraryItems, + total: newestEpisodesPayload.count + }) + } + Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } - }) - LibraryItem.addHook('afterDestroy', async instance => { - if (!instance) return - const media = await instance.getMedia() - if (media) { - media.destroy() + start = Date.now() + // "Recently Added" shelf + const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit) + if (mostRecentPayload.libraryItems.length) { + shelves.push({ + id: 'recently-added', + label: 'Recently Added', + labelStringKey: 'LabelRecentlyAdded', + type: library.mediaType, + entities: mostRecentPayload.libraryItems, + total: mostRecentPayload.count + }) } - }) + Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - return LibraryItem -} \ No newline at end of file + if (library.isBook) { + start = Date.now() + // "Recent Series" shelf + const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5) + if (seriesMostRecentPayload.series.length) { + shelves.push({ + id: 'recent-series', + label: 'Recent Series', + labelStringKey: 'LabelRecentSeries', + type: 'series', + entities: seriesMostRecentPayload.series, + total: seriesMostRecentPayload.count + }) + } + Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + + start = Date.now() + // "Discover" shelf + const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, user, include, limit) + if (discoverLibraryItemsPayload.libraryItems.length) { + shelves.push({ + id: 'discover', + label: 'Discover', + labelStringKey: 'LabelDiscover', + type: library.mediaType, + entities: discoverLibraryItemsPayload.libraryItems, + total: discoverLibraryItemsPayload.count + }) + } + Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + } + + start = Date.now() + // "Listen Again" shelf + const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit) + if (mediaFinishedPayload.items.length) { + const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter(li => li.media.isEBookOnly) + const audioOnlyItemsInProgress = mediaFinishedPayload.items.filter(li => !li.media.isEBookOnly) + + shelves.push({ + id: 'listen-again', + label: 'Listen Again', + labelStringKey: 'LabelListenAgain', + type: library.isPodcast ? 'episode' : 'book', + entities: audioOnlyItemsInProgress, + total: mediaFinishedPayload.count + }) + + // "Read Again" shelf + if (ebookOnlyItemsInProgress.length) { + shelves.push({ + id: 'read-again', + label: 'Read Again', + labelStringKey: 'LabelReadAgain', + type: 'book', + entities: ebookOnlyItemsInProgress, + total: mediaFinishedPayload.count + }) + } + } + Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + + if (library.isBook) { + start = Date.now() + // "Newest Authors" shelf + const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, user, limit) + if (newestAuthorsPayload.authors.length) { + shelves.push({ + id: 'newest-authors', + label: 'Newest Authors', + labelStringKey: 'LabelNewestAuthors', + type: 'authors', + entities: newestAuthorsPayload.authors, + total: newestAuthorsPayload.count + }) + } + Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + } + + Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) + + return shelves + } + + /** + * Get book library items for author, optional use user permissions + * @param {oldAuthor} author + * @param {[oldUser]} user + * @returns {Promise} + */ + static async getForAuthor(author, user = null) { + const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined) + return libraryItems.map(li => this.getOldLibraryItem(li)) + } + + /** + * Get book library items in a collection + * @param {oldCollection} collection + * @returns {Promise} + */ + static async getForCollection(collection) { + const libraryItems = await libraryFilters.getLibraryItemsForCollection(collection) + return libraryItems.map(li => this.getOldLibraryItem(li)) + } + + /** + * Check if library item exists + * @param {string} libraryItemId + * @returns {Promise} + */ + static async checkExistsById(libraryItemId) { + return (await this.count({ where: { id: libraryItemId } })) > 0 + } + + getMedia(options) { + if (!this.mediaType) return Promise.resolve(null) + const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}` + return this[mixinMethodName](options) + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + ino: DataTypes.STRING, + path: DataTypes.STRING, + relPath: DataTypes.STRING, + mediaId: DataTypes.UUIDV4, + mediaType: DataTypes.STRING, + isFile: DataTypes.BOOLEAN, + isMissing: DataTypes.BOOLEAN, + isInvalid: DataTypes.BOOLEAN, + mtime: DataTypes.DATE(6), + ctime: DataTypes.DATE(6), + birthtime: DataTypes.DATE(6), + size: DataTypes.BIGINT, + lastScan: DataTypes.DATE, + lastScanVersion: DataTypes.STRING, + libraryFiles: DataTypes.JSON, + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'libraryItem', + indexes: [ + { + fields: ['createdAt'] + }, + { + fields: ['mediaId'] + }, + { + fields: ['libraryId', 'mediaType'] + }, + { + fields: ['birthtime'] + }, + { + fields: ['mtime'] + } + ] + }) + + const { library, libraryFolder, book, podcast } = sequelize.models + library.hasMany(LibraryItem) + LibraryItem.belongsTo(library) + + libraryFolder.hasMany(LibraryItem) + LibraryItem.belongsTo(libraryFolder) + + book.hasOne(LibraryItem, { + foreignKey: 'mediaId', + constraints: false, + scope: { + mediaType: 'book' + } + }) + LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false }) + + podcast.hasOne(LibraryItem, { + foreignKey: 'mediaId', + constraints: false, + scope: { + mediaType: 'podcast' + } + }) + LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false }) + + LibraryItem.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + for (const instance of findResult) { + if (instance.mediaType === 'book' && instance.book !== undefined) { + instance.media = instance.book + instance.dataValues.media = instance.dataValues.book + } else if (instance.mediaType === 'podcast' && instance.podcast !== undefined) { + instance.media = instance.podcast + instance.dataValues.media = instance.dataValues.podcast + } + // To prevent mistakes: + delete instance.book + delete instance.dataValues.book + delete instance.podcast + delete instance.dataValues.podcast + } + }) + + LibraryItem.addHook('afterDestroy', async instance => { + if (!instance) return + const media = await instance.getMedia() + if (media) { + media.destroy() + } + }) + } +} + +module.exports = LibraryItem diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index 1986605d..6214d649 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -1,148 +1,184 @@ const { DataTypes, Model } = require('sequelize') -/* - * Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ - * Book has many MediaProgress. PodcastEpisode has many MediaProgress. - */ -module.exports = (sequelize) => { - class MediaProgress extends Model { - getOldMediaProgress() { - const isPodcastEpisode = this.mediaItemType === 'podcastEpisode' +class MediaProgress extends Model { + constructor(values, options) { + super(values, options) - return { - id: this.id, - userId: this.userId, - libraryItemId: this.extraData?.libraryItemId || null, - episodeId: isPodcastEpisode ? this.mediaItemId : null, - mediaItemId: this.mediaItemId, - mediaItemType: this.mediaItemType, - duration: this.duration, - progress: this.extraData?.progress || 0, - currentTime: this.currentTime, - isFinished: !!this.isFinished, - hideFromContinueListening: !!this.hideFromContinueListening, - ebookLocation: this.ebookLocation, - ebookProgress: this.ebookProgress, - lastUpdate: this.updatedAt.valueOf(), - startedAt: this.createdAt.valueOf(), - finishedAt: this.finishedAt?.valueOf() || null - } - } + /** @type {UUIDV4} */ + this.id + /** @type {UUIDV4} */ + this.mediaItemId + /** @type {string} */ + this.mediaItemType + /** @type {number} */ + this.duration + /** @type {number} */ + this.currentTime + /** @type {boolean} */ + this.isFinished + /** @type {boolean} */ + this.hideFromContinueListening + /** @type {string} */ + this.ebookLocation + /** @type {number} */ + this.ebookProgress + /** @type {Date} */ + this.finishedAt + /** @type {Object} */ + this.extraData + /** @type {UUIDV4} */ + this.userId + /** @type {Date} */ + this.updatedAt + /** @type {Date} */ + this.createdAt + } - static upsertFromOld(oldMediaProgress) { - const mediaProgress = this.getFromOld(oldMediaProgress) - return this.upsert(mediaProgress) - } + getOldMediaProgress() { + const isPodcastEpisode = this.mediaItemType === 'podcastEpisode' - static getFromOld(oldMediaProgress) { - return { - id: oldMediaProgress.id, - userId: oldMediaProgress.userId, - mediaItemId: oldMediaProgress.mediaItemId, - mediaItemType: oldMediaProgress.mediaItemType, - duration: oldMediaProgress.duration, - currentTime: oldMediaProgress.currentTime, - ebookLocation: oldMediaProgress.ebookLocation || null, - ebookProgress: oldMediaProgress.ebookProgress || null, - isFinished: !!oldMediaProgress.isFinished, - hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening, - finishedAt: oldMediaProgress.finishedAt, - createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate, - updatedAt: oldMediaProgress.lastUpdate, - extraData: { - libraryItemId: oldMediaProgress.libraryItemId, - progress: oldMediaProgress.progress - } - } - } - - static removeById(mediaProgressId) { - return this.destroy({ - where: { - id: mediaProgressId - } - }) - } - - getMediaItem(options) { - if (!this.mediaItemType) return Promise.resolve(null) - const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` - return this[mixinMethodName](options) + return { + id: this.id, + userId: this.userId, + libraryItemId: this.extraData?.libraryItemId || null, + episodeId: isPodcastEpisode ? this.mediaItemId : null, + mediaItemId: this.mediaItemId, + mediaItemType: this.mediaItemType, + duration: this.duration, + progress: this.extraData?.progress || 0, + currentTime: this.currentTime, + isFinished: !!this.isFinished, + hideFromContinueListening: !!this.hideFromContinueListening, + ebookLocation: this.ebookLocation, + ebookProgress: this.ebookProgress, + lastUpdate: this.updatedAt.valueOf(), + startedAt: this.createdAt.valueOf(), + finishedAt: this.finishedAt?.valueOf() || null } } + static upsertFromOld(oldMediaProgress) { + const mediaProgress = this.getFromOld(oldMediaProgress) + return this.upsert(mediaProgress) + } - MediaProgress.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - mediaItemId: DataTypes.UUIDV4, - mediaItemType: DataTypes.STRING, - duration: DataTypes.FLOAT, - currentTime: DataTypes.FLOAT, - isFinished: DataTypes.BOOLEAN, - hideFromContinueListening: DataTypes.BOOLEAN, - ebookLocation: DataTypes.STRING, - ebookProgress: DataTypes.FLOAT, - finishedAt: DataTypes.DATE, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'mediaProgress', - indexes: [ - { - fields: ['updatedAt'] + static getFromOld(oldMediaProgress) { + return { + id: oldMediaProgress.id, + userId: oldMediaProgress.userId, + mediaItemId: oldMediaProgress.mediaItemId, + mediaItemType: oldMediaProgress.mediaItemType, + duration: oldMediaProgress.duration, + currentTime: oldMediaProgress.currentTime, + ebookLocation: oldMediaProgress.ebookLocation || null, + ebookProgress: oldMediaProgress.ebookProgress || null, + isFinished: !!oldMediaProgress.isFinished, + hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening, + finishedAt: oldMediaProgress.finishedAt, + createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate, + updatedAt: oldMediaProgress.lastUpdate, + extraData: { + libraryItemId: oldMediaProgress.libraryItemId, + progress: oldMediaProgress.progress } - ] - }) - - const { book, podcastEpisode, user } = sequelize.models - - book.hasMany(MediaProgress, { - foreignKey: 'mediaItemId', - constraints: false, - scope: { - mediaItemType: 'book' } - }) - MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) + } - podcastEpisode.hasMany(MediaProgress, { - foreignKey: 'mediaItemId', - constraints: false, - scope: { - mediaItemType: 'podcastEpisode' - } - }) - MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) - - MediaProgress.addHook('afterFind', findResult => { - if (!findResult) return - - if (!Array.isArray(findResult)) findResult = [findResult] - - for (const instance of findResult) { - if (instance.mediaItemType === 'book' && instance.book !== undefined) { - instance.mediaItem = instance.book - instance.dataValues.mediaItem = instance.dataValues.book - } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { - instance.mediaItem = instance.podcastEpisode - instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + static removeById(mediaProgressId) { + return this.destroy({ + where: { + id: mediaProgressId } - // To prevent mistakes: - delete instance.book - delete instance.dataValues.book - delete instance.podcastEpisode - delete instance.dataValues.podcastEpisode - } - }) + }) + } - user.hasMany(MediaProgress, { - onDelete: 'CASCADE' - }) - MediaProgress.belongsTo(user) + getMediaItem(options) { + if (!this.mediaItemType) return Promise.resolve(null) + const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}` + return this[mixinMethodName](options) + } - return MediaProgress -} \ No newline at end of file + /** + * Initialize model + * + * Polymorphic association: Book has many MediaProgress. PodcastEpisode has many MediaProgress. + * @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ + * + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + mediaItemId: DataTypes.UUIDV4, + mediaItemType: DataTypes.STRING, + duration: DataTypes.FLOAT, + currentTime: DataTypes.FLOAT, + isFinished: DataTypes.BOOLEAN, + hideFromContinueListening: DataTypes.BOOLEAN, + ebookLocation: DataTypes.STRING, + ebookProgress: DataTypes.FLOAT, + finishedAt: DataTypes.DATE, + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'mediaProgress', + indexes: [ + { + fields: ['updatedAt'] + } + ] + }) + + const { book, podcastEpisode, user } = sequelize.models + + book.hasMany(MediaProgress, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'book' + } + }) + MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) + + podcastEpisode.hasMany(MediaProgress, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'podcastEpisode' + } + }) + MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + + MediaProgress.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + + for (const instance of findResult) { + if (instance.mediaItemType === 'book' && instance.book !== undefined) { + instance.mediaItem = instance.book + instance.dataValues.mediaItem = instance.dataValues.book + } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { + instance.mediaItem = instance.podcastEpisode + instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + } + // To prevent mistakes: + delete instance.book + delete instance.dataValues.book + delete instance.podcastEpisode + delete instance.dataValues.podcastEpisode + } + }) + + user.hasMany(MediaProgress, { + onDelete: 'CASCADE' + }) + MediaProgress.belongsTo(user) + } +} + +module.exports = MediaProgress \ No newline at end of file diff --git a/server/models/PlaybackSession.js b/server/models/PlaybackSession.js index 0e0e04b5..3bb8653b 100644 --- a/server/models/PlaybackSession.js +++ b/server/models/PlaybackSession.js @@ -2,197 +2,251 @@ const { DataTypes, Model } = require('sequelize') const oldPlaybackSession = require('../objects/PlaybackSession') -module.exports = (sequelize) => { - class PlaybackSession extends Model { - static async getOldPlaybackSessions(where = null) { - const playbackSessions = await this.findAll({ - where, - include: [ - { - model: sequelize.models.device - } - ] - }) - return playbackSessions.map(session => this.getOldPlaybackSession(session)) - } - static async getById(sessionId) { - const playbackSession = await this.findByPk(sessionId, { - include: [ - { - model: sequelize.models.device - } - ] - }) - if (!playbackSession) return null - return this.getOldPlaybackSession(playbackSession) - } +class PlaybackSession extends Model { + constructor(values, options) { + super(values, options) - static getOldPlaybackSession(playbackSessionExpanded) { - const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode' + /** @type {UUIDV4} */ + this.id + /** @type {UUIDV4} */ + this.mediaItemId + /** @type {string} */ + this.mediaItemType + /** @type {string} */ + this.displayTitle + /** @type {string} */ + this.displayAuthor + /** @type {number} */ + this.duration + /** @type {number} */ + this.playMethod + /** @type {string} */ + this.mediaPlayer + /** @type {number} */ + this.startTime + /** @type {number} */ + this.currentTime + /** @type {string} */ + this.serverVersion + /** @type {string} */ + this.coverPath + /** @type {number} */ + this.timeListening + /** @type {Object} */ + this.mediaMetadata + /** @type {string} */ + this.date + /** @type {string} */ + this.dayOfWeek + /** @type {Object} */ + this.extraData + /** @type {UUIDV4} */ + this.userId + /** @type {UUIDV4} */ + this.deviceId + /** @type {UUIDV4} */ + this.libraryId + /** @type {Date} */ + this.updatedAt + /** @type {Date} */ + this.createdAt + } - return new oldPlaybackSession({ - id: playbackSessionExpanded.id, - userId: playbackSessionExpanded.userId, - libraryId: playbackSessionExpanded.libraryId, - libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null, - bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId, - episodeId: isPodcastEpisode ? playbackSessionExpanded.mediaItemId : null, - mediaType: isPodcastEpisode ? 'podcast' : 'book', - mediaMetadata: playbackSessionExpanded.mediaMetadata, - chapters: null, - displayTitle: playbackSessionExpanded.displayTitle, - displayAuthor: playbackSessionExpanded.displayAuthor, - coverPath: playbackSessionExpanded.coverPath, - duration: playbackSessionExpanded.duration, - playMethod: playbackSessionExpanded.playMethod, - mediaPlayer: playbackSessionExpanded.mediaPlayer, - deviceInfo: playbackSessionExpanded.device?.getOldDevice() || null, - serverVersion: playbackSessionExpanded.serverVersion, - date: playbackSessionExpanded.date, - dayOfWeek: playbackSessionExpanded.dayOfWeek, - timeListening: playbackSessionExpanded.timeListening, - startTime: playbackSessionExpanded.startTime, - currentTime: playbackSessionExpanded.currentTime, - startedAt: playbackSessionExpanded.createdAt.valueOf(), - updatedAt: playbackSessionExpanded.updatedAt.valueOf() - }) - } - - static removeById(sessionId) { - return this.destroy({ - where: { - id: sessionId + static async getOldPlaybackSessions(where = null) { + const playbackSessions = await this.findAll({ + where, + include: [ + { + model: this.sequelize.models.device } - }) - } + ] + }) + return playbackSessions.map(session => this.getOldPlaybackSession(session)) + } - static createFromOld(oldPlaybackSession) { - const playbackSession = this.getFromOld(oldPlaybackSession) - return this.create(playbackSession) - } - - static updateFromOld(oldPlaybackSession) { - const playbackSession = this.getFromOld(oldPlaybackSession) - return this.update(playbackSession, { - where: { - id: playbackSession.id + static async getById(sessionId) { + const playbackSession = await this.findByPk(sessionId, { + include: [ + { + model: this.sequelize.models.device } - }) - } + ] + }) + if (!playbackSession) return null + return this.getOldPlaybackSession(playbackSession) + } - static getFromOld(oldPlaybackSession) { - return { - id: oldPlaybackSession.id, - mediaItemId: oldPlaybackSession.episodeId || oldPlaybackSession.bookId, - mediaItemType: oldPlaybackSession.episodeId ? 'podcastEpisode' : 'book', - libraryId: oldPlaybackSession.libraryId, - displayTitle: oldPlaybackSession.displayTitle, - displayAuthor: oldPlaybackSession.displayAuthor, - duration: oldPlaybackSession.duration, - playMethod: oldPlaybackSession.playMethod, - mediaPlayer: oldPlaybackSession.mediaPlayer, - startTime: oldPlaybackSession.startTime, - currentTime: oldPlaybackSession.currentTime, - serverVersion: oldPlaybackSession.serverVersion || null, - createdAt: oldPlaybackSession.startedAt, - updatedAt: oldPlaybackSession.updatedAt, - userId: oldPlaybackSession.userId, - deviceId: oldPlaybackSession.deviceInfo?.id || null, - timeListening: oldPlaybackSession.timeListening, - coverPath: oldPlaybackSession.coverPath, - mediaMetadata: oldPlaybackSession.mediaMetadata, - date: oldPlaybackSession.date, - dayOfWeek: oldPlaybackSession.dayOfWeek, - extraData: { - libraryItemId: oldPlaybackSession.libraryItemId - } + static getOldPlaybackSession(playbackSessionExpanded) { + const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode' + + return new oldPlaybackSession({ + id: playbackSessionExpanded.id, + userId: playbackSessionExpanded.userId, + libraryId: playbackSessionExpanded.libraryId, + libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null, + bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId, + episodeId: isPodcastEpisode ? playbackSessionExpanded.mediaItemId : null, + mediaType: isPodcastEpisode ? 'podcast' : 'book', + mediaMetadata: playbackSessionExpanded.mediaMetadata, + chapters: null, + displayTitle: playbackSessionExpanded.displayTitle, + displayAuthor: playbackSessionExpanded.displayAuthor, + coverPath: playbackSessionExpanded.coverPath, + duration: playbackSessionExpanded.duration, + playMethod: playbackSessionExpanded.playMethod, + mediaPlayer: playbackSessionExpanded.mediaPlayer, + deviceInfo: playbackSessionExpanded.device?.getOldDevice() || null, + serverVersion: playbackSessionExpanded.serverVersion, + date: playbackSessionExpanded.date, + dayOfWeek: playbackSessionExpanded.dayOfWeek, + timeListening: playbackSessionExpanded.timeListening, + startTime: playbackSessionExpanded.startTime, + currentTime: playbackSessionExpanded.currentTime, + startedAt: playbackSessionExpanded.createdAt.valueOf(), + updatedAt: playbackSessionExpanded.updatedAt.valueOf() + }) + } + + static removeById(sessionId) { + return this.destroy({ + where: { + id: sessionId } - } + }) + } - getMediaItem(options) { - if (!this.mediaItemType) return Promise.resolve(null) - const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` - return this[mixinMethodName](options) + static createFromOld(oldPlaybackSession) { + const playbackSession = this.getFromOld(oldPlaybackSession) + return this.create(playbackSession) + } + + static updateFromOld(oldPlaybackSession) { + const playbackSession = this.getFromOld(oldPlaybackSession) + return this.update(playbackSession, { + where: { + id: playbackSession.id + } + }) + } + + static getFromOld(oldPlaybackSession) { + return { + id: oldPlaybackSession.id, + mediaItemId: oldPlaybackSession.episodeId || oldPlaybackSession.bookId, + mediaItemType: oldPlaybackSession.episodeId ? 'podcastEpisode' : 'book', + libraryId: oldPlaybackSession.libraryId, + displayTitle: oldPlaybackSession.displayTitle, + displayAuthor: oldPlaybackSession.displayAuthor, + duration: oldPlaybackSession.duration, + playMethod: oldPlaybackSession.playMethod, + mediaPlayer: oldPlaybackSession.mediaPlayer, + startTime: oldPlaybackSession.startTime, + currentTime: oldPlaybackSession.currentTime, + serverVersion: oldPlaybackSession.serverVersion || null, + createdAt: oldPlaybackSession.startedAt, + updatedAt: oldPlaybackSession.updatedAt, + userId: oldPlaybackSession.userId, + deviceId: oldPlaybackSession.deviceInfo?.id || null, + timeListening: oldPlaybackSession.timeListening, + coverPath: oldPlaybackSession.coverPath, + mediaMetadata: oldPlaybackSession.mediaMetadata, + date: oldPlaybackSession.date, + dayOfWeek: oldPlaybackSession.dayOfWeek, + extraData: { + libraryItemId: oldPlaybackSession.libraryItemId + } } } - PlaybackSession.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - mediaItemId: DataTypes.UUIDV4, - mediaItemType: DataTypes.STRING, - displayTitle: DataTypes.STRING, - displayAuthor: DataTypes.STRING, - duration: DataTypes.FLOAT, - playMethod: DataTypes.INTEGER, - mediaPlayer: DataTypes.STRING, - startTime: DataTypes.FLOAT, - currentTime: DataTypes.FLOAT, - serverVersion: DataTypes.STRING, - coverPath: DataTypes.STRING, - timeListening: DataTypes.INTEGER, - mediaMetadata: DataTypes.JSON, - date: DataTypes.STRING, - dayOfWeek: DataTypes.STRING, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'playbackSession' - }) + getMediaItem(options) { + if (!this.mediaItemType) return Promise.resolve(null) + const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}` + return this[mixinMethodName](options) + } - const { book, podcastEpisode, user, device, library } = sequelize.models + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + mediaItemId: DataTypes.UUIDV4, + mediaItemType: DataTypes.STRING, + displayTitle: DataTypes.STRING, + displayAuthor: DataTypes.STRING, + duration: DataTypes.FLOAT, + playMethod: DataTypes.INTEGER, + mediaPlayer: DataTypes.STRING, + startTime: DataTypes.FLOAT, + currentTime: DataTypes.FLOAT, + serverVersion: DataTypes.STRING, + coverPath: DataTypes.STRING, + timeListening: DataTypes.INTEGER, + mediaMetadata: DataTypes.JSON, + date: DataTypes.STRING, + dayOfWeek: DataTypes.STRING, + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'playbackSession' + }) - user.hasMany(PlaybackSession) - PlaybackSession.belongsTo(user) + const { book, podcastEpisode, user, device, library } = sequelize.models - device.hasMany(PlaybackSession) - PlaybackSession.belongsTo(device) + user.hasMany(PlaybackSession) + PlaybackSession.belongsTo(user) - library.hasMany(PlaybackSession) - PlaybackSession.belongsTo(library) + device.hasMany(PlaybackSession) + PlaybackSession.belongsTo(device) - book.hasMany(PlaybackSession, { - foreignKey: 'mediaItemId', - constraints: false, - scope: { - mediaItemType: 'book' - } - }) - PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) + library.hasMany(PlaybackSession) + PlaybackSession.belongsTo(library) - podcastEpisode.hasOne(PlaybackSession, { - foreignKey: 'mediaItemId', - constraints: false, - scope: { - mediaItemType: 'podcastEpisode' - } - }) - PlaybackSession.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) - - PlaybackSession.addHook('afterFind', findResult => { - if (!findResult) return - - if (!Array.isArray(findResult)) findResult = [findResult] - - for (const instance of findResult) { - if (instance.mediaItemType === 'book' && instance.book !== undefined) { - instance.mediaItem = instance.book - instance.dataValues.mediaItem = instance.dataValues.book - } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { - instance.mediaItem = instance.podcastEpisode - instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + book.hasMany(PlaybackSession, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'book' } - // To prevent mistakes: - delete instance.book - delete instance.dataValues.book - delete instance.podcastEpisode - delete instance.dataValues.podcastEpisode - } - }) + }) + PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) - return PlaybackSession -} \ No newline at end of file + podcastEpisode.hasOne(PlaybackSession, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'podcastEpisode' + } + }) + PlaybackSession.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + + PlaybackSession.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + + for (const instance of findResult) { + if (instance.mediaItemType === 'book' && instance.book !== undefined) { + instance.mediaItem = instance.book + instance.dataValues.mediaItem = instance.dataValues.book + } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { + instance.mediaItem = instance.podcastEpisode + instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + } + // To prevent mistakes: + delete instance.book + delete instance.dataValues.book + delete instance.podcastEpisode + delete instance.dataValues.podcastEpisode + } + }) + } +} + +module.exports = PlaybackSession diff --git a/server/models/Playlist.js b/server/models/Playlist.js index 7e466289..d6a86d68 100644 --- a/server/models/Playlist.js +++ b/server/models/Playlist.js @@ -3,318 +3,341 @@ const Logger = require('../Logger') const oldPlaylist = require('../objects/Playlist') -module.exports = (sequelize) => { - class Playlist extends Model { - static async getOldPlaylists() { - const playlists = await this.findAll({ - include: { - model: sequelize.models.playlistMediaItem, - include: [ - { - model: sequelize.models.book, - include: sequelize.models.libraryItem - }, - { - model: sequelize.models.podcastEpisode, - include: { - model: sequelize.models.podcast, - include: sequelize.models.libraryItem - } - } - ] - }, - order: [['playlistMediaItems', 'order', 'ASC']] - }) - return playlists.map(p => this.getOldPlaylist(p)) - } +class Playlist extends Model { + constructor(values, options) { + super(values, options) - static getOldPlaylist(playlistExpanded) { - const items = playlistExpanded.playlistMediaItems.map(pmi => { - const libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || pmi.mediaItem?.libraryItem?.id || null - if (!libraryItemId) { - Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2)) - return null - } - return { - episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '', - libraryItemId - } - }).filter(pmi => pmi) + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.name + /** @type {string} */ + this.description + /** @type {UUIDV4} */ + this.libraryId + /** @type {UUIDV4} */ + this.userId + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } - return new oldPlaylist({ - id: playlistExpanded.id, - libraryId: playlistExpanded.libraryId, - userId: playlistExpanded.userId, - name: playlistExpanded.name, - description: playlistExpanded.description, - items, - lastUpdate: playlistExpanded.updatedAt.valueOf(), - createdAt: playlistExpanded.createdAt.valueOf() - }) - } - - /** - * Get old playlist toJSONExpanded - * @param {[string[]]} include - * @returns {Promise} oldPlaylist.toJSONExpanded - */ - async getOldJsonExpanded(include) { - this.playlistMediaItems = await this.getPlaylistMediaItems({ + static async getOldPlaylists() { + const playlists = await this.findAll({ + include: { + model: this.sequelize.models.playlistMediaItem, include: [ { - model: sequelize.models.book, - include: sequelize.models.libraryItem + model: this.sequelize.models.book, + include: this.sequelize.models.libraryItem }, { - model: sequelize.models.podcastEpisode, + model: this.sequelize.models.podcastEpisode, include: { - model: sequelize.models.podcast, - include: sequelize.models.libraryItem + model: this.sequelize.models.podcast, + include: this.sequelize.models.libraryItem } } - ], - order: [['order', 'ASC']] - }) || [] - - const oldPlaylist = sequelize.models.playlist.getOldPlaylist(this) - const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId) - - let libraryItems = await sequelize.models.libraryItem.getAllOldLibraryItems({ - id: libraryItemIds - }) - - const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems) - - if (include?.includes('rssfeed')) { - const feeds = await this.getFeeds() - if (feeds?.length) { - playlistExpanded.rssFeed = sequelize.models.feed.getOldFeed(feeds[0]) - } - } - - return playlistExpanded - } - - static createFromOld(oldPlaylist) { - const playlist = this.getFromOld(oldPlaylist) - return this.create(playlist) - } - - static getFromOld(oldPlaylist) { - return { - id: oldPlaylist.id, - name: oldPlaylist.name, - description: oldPlaylist.description, - userId: oldPlaylist.userId, - libraryId: oldPlaylist.libraryId - } - } - - static removeById(playlistId) { - return this.destroy({ - where: { - id: playlistId - } - }) - } - - /** - * Get playlist by id - * @param {string} playlistId - * @returns {Promise} returns null if not found - */ - static async getById(playlistId) { - if (!playlistId) return null - const playlist = await this.findByPk(playlistId, { - include: { - model: sequelize.models.playlistMediaItem, - include: [ - { - model: sequelize.models.book, - include: sequelize.models.libraryItem - }, - { - model: sequelize.models.podcastEpisode, - include: { - model: sequelize.models.podcast, - include: sequelize.models.libraryItem - } - } - ] - }, - order: [['playlistMediaItems', 'order', 'ASC']] - }) - if (!playlist) return null - return this.getOldPlaylist(playlist) - } - - /** - * Get playlists for user and optionally for library - * @param {string} userId - * @param {[string]} libraryId optional - * @returns {Promise} - */ - static async getPlaylistsForUserAndLibrary(userId, libraryId = null) { - if (!userId && !libraryId) return [] - const whereQuery = {} - if (userId) { - whereQuery.userId = userId - } - if (libraryId) { - whereQuery.libraryId = libraryId - } - const playlists = await this.findAll({ - where: whereQuery, - include: { - model: sequelize.models.playlistMediaItem, - include: [ - { - model: sequelize.models.book, - include: sequelize.models.libraryItem - }, - { - model: sequelize.models.podcastEpisode, - include: { - model: sequelize.models.podcast, - include: sequelize.models.libraryItem - } - } - ] - }, - order: [ - [literal('name COLLATE NOCASE'), 'ASC'], - ['playlistMediaItems', 'order', 'ASC'] ] - }) - return playlists - } + }, + order: [['playlistMediaItems', 'order', 'ASC']] + }) + return playlists.map(p => this.getOldPlaylist(p)) + } - /** - * Get number of playlists for a user and library - * @param {string} userId - * @param {string} libraryId - * @returns - */ - static async getNumPlaylistsForUserAndLibrary(userId, libraryId) { - return this.count({ - where: { - userId, - libraryId - } - }) - } - - /** - * Get all playlists for mediaItemIds - * @param {string[]} mediaItemIds - * @returns {Promise} - */ - static async getPlaylistsForMediaItemIds(mediaItemIds) { - if (!mediaItemIds?.length) return [] - - const playlistMediaItemsExpanded = await sequelize.models.playlistMediaItem.findAll({ - where: { - mediaItemId: { - [Op.in]: mediaItemIds - } - }, - include: [ - { - model: sequelize.models.playlist, - include: { - model: sequelize.models.playlistMediaItem, - include: [ - { - model: sequelize.models.book, - include: sequelize.models.libraryItem - }, - { - model: sequelize.models.podcastEpisode, - include: { - model: sequelize.models.podcast, - include: sequelize.models.libraryItem - } - } - ] - } - } - ], - order: [['playlist', 'playlistMediaItems', 'order', 'ASC']] - }) - - const playlists = [] - for (const playlistMediaItem of playlistMediaItemsExpanded) { - const playlist = playlistMediaItem.playlist - if (playlists.some(p => p.id === playlist.id)) continue - - playlist.playlistMediaItems = playlist.playlistMediaItems.map(pmi => { - if (pmi.mediaItemType === 'book' && pmi.book !== undefined) { - pmi.mediaItem = pmi.book - pmi.dataValues.mediaItem = pmi.dataValues.book - } else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) { - pmi.mediaItem = pmi.podcastEpisode - pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode - } - delete pmi.book - delete pmi.dataValues.book - delete pmi.podcastEpisode - delete pmi.dataValues.podcastEpisode - return pmi - }) - playlists.push(playlist) + static getOldPlaylist(playlistExpanded) { + const items = playlistExpanded.playlistMediaItems.map(pmi => { + const libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || pmi.mediaItem?.libraryItem?.id || null + if (!libraryItemId) { + Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2)) + return null } - return playlists + return { + episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '', + libraryItemId + } + }).filter(pmi => pmi) + + return new oldPlaylist({ + id: playlistExpanded.id, + libraryId: playlistExpanded.libraryId, + userId: playlistExpanded.userId, + name: playlistExpanded.name, + description: playlistExpanded.description, + items, + lastUpdate: playlistExpanded.updatedAt.valueOf(), + createdAt: playlistExpanded.createdAt.valueOf() + }) + } + + /** + * Get old playlist toJSONExpanded + * @param {[string[]]} include + * @returns {Promise} oldPlaylist.toJSONExpanded + */ + async getOldJsonExpanded(include) { + this.playlistMediaItems = await this.getPlaylistMediaItems({ + include: [ + { + model: this.sequelize.models.book, + include: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.podcastEpisode, + include: { + model: this.sequelize.models.podcast, + include: this.sequelize.models.libraryItem + } + } + ], + order: [['order', 'ASC']] + }) || [] + + const oldPlaylist = this.sequelize.models.playlist.getOldPlaylist(this) + const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId) + + let libraryItems = await this.sequelize.models.libraryItem.getAllOldLibraryItems({ + id: libraryItemIds + }) + + const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems) + + if (include?.includes('rssfeed')) { + const feeds = await this.getFeeds() + if (feeds?.length) { + playlistExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0]) + } + } + + return playlistExpanded + } + + static createFromOld(oldPlaylist) { + const playlist = this.getFromOld(oldPlaylist) + return this.create(playlist) + } + + static getFromOld(oldPlaylist) { + return { + id: oldPlaylist.id, + name: oldPlaylist.name, + description: oldPlaylist.description, + userId: oldPlaylist.userId, + libraryId: oldPlaylist.libraryId } } - Playlist.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - name: DataTypes.STRING, - description: DataTypes.TEXT - }, { - sequelize, - modelName: 'playlist' - }) - - const { library, user } = sequelize.models - library.hasMany(Playlist) - Playlist.belongsTo(library) - - user.hasMany(Playlist, { - onDelete: 'CASCADE' - }) - Playlist.belongsTo(user) - - Playlist.addHook('afterFind', findResult => { - if (!findResult) return - - if (!Array.isArray(findResult)) findResult = [findResult] - - for (const instance of findResult) { - if (instance.playlistMediaItems?.length) { - instance.playlistMediaItems = instance.playlistMediaItems.map(pmi => { - if (pmi.mediaItemType === 'book' && pmi.book !== undefined) { - pmi.mediaItem = pmi.book - pmi.dataValues.mediaItem = pmi.dataValues.book - } else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) { - pmi.mediaItem = pmi.podcastEpisode - pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode - } - // To prevent mistakes: - delete pmi.book - delete pmi.dataValues.book - delete pmi.podcastEpisode - delete pmi.dataValues.podcastEpisode - return pmi - }) + static removeById(playlistId) { + return this.destroy({ + where: { + id: playlistId } + }) + } + /** + * Get playlist by id + * @param {string} playlistId + * @returns {Promise} returns null if not found + */ + static async getById(playlistId) { + if (!playlistId) return null + const playlist = await this.findByPk(playlistId, { + include: { + model: this.sequelize.models.playlistMediaItem, + include: [ + { + model: this.sequelize.models.book, + include: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.podcastEpisode, + include: { + model: this.sequelize.models.podcast, + include: this.sequelize.models.libraryItem + } + } + ] + }, + order: [['playlistMediaItems', 'order', 'ASC']] + }) + if (!playlist) return null + return this.getOldPlaylist(playlist) + } + + /** + * Get playlists for user and optionally for library + * @param {string} userId + * @param {[string]} libraryId optional + * @returns {Promise} + */ + static async getPlaylistsForUserAndLibrary(userId, libraryId = null) { + if (!userId && !libraryId) return [] + const whereQuery = {} + if (userId) { + whereQuery.userId = userId } - }) + if (libraryId) { + whereQuery.libraryId = libraryId + } + const playlists = await this.findAll({ + where: whereQuery, + include: { + model: this.sequelize.models.playlistMediaItem, + include: [ + { + model: this.sequelize.models.book, + include: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.podcastEpisode, + include: { + model: this.sequelize.models.podcast, + include: this.sequelize.models.libraryItem + } + } + ] + }, + order: [ + [literal('name COLLATE NOCASE'), 'ASC'], + ['playlistMediaItems', 'order', 'ASC'] + ] + }) + return playlists + } - return Playlist -} \ No newline at end of file + /** + * Get number of playlists for a user and library + * @param {string} userId + * @param {string} libraryId + * @returns + */ + static async getNumPlaylistsForUserAndLibrary(userId, libraryId) { + return this.count({ + where: { + userId, + libraryId + } + }) + } + + /** + * Get all playlists for mediaItemIds + * @param {string[]} mediaItemIds + * @returns {Promise} + */ + static async getPlaylistsForMediaItemIds(mediaItemIds) { + if (!mediaItemIds?.length) return [] + + const playlistMediaItemsExpanded = await this.sequelize.models.playlistMediaItem.findAll({ + where: { + mediaItemId: { + [Op.in]: mediaItemIds + } + }, + include: [ + { + model: this.sequelize.models.playlist, + include: { + model: this.sequelize.models.playlistMediaItem, + include: [ + { + model: this.sequelize.models.book, + include: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.podcastEpisode, + include: { + model: this.sequelize.models.podcast, + include: this.sequelize.models.libraryItem + } + } + ] + } + } + ], + order: [['playlist', 'playlistMediaItems', 'order', 'ASC']] + }) + + const playlists = [] + for (const playlistMediaItem of playlistMediaItemsExpanded) { + const playlist = playlistMediaItem.playlist + if (playlists.some(p => p.id === playlist.id)) continue + + playlist.playlistMediaItems = playlist.playlistMediaItems.map(pmi => { + if (pmi.mediaItemType === 'book' && pmi.book !== undefined) { + pmi.mediaItem = pmi.book + pmi.dataValues.mediaItem = pmi.dataValues.book + } else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) { + pmi.mediaItem = pmi.podcastEpisode + pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode + } + delete pmi.book + delete pmi.dataValues.book + delete pmi.podcastEpisode + delete pmi.dataValues.podcastEpisode + return pmi + }) + playlists.push(playlist) + } + return playlists + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + description: DataTypes.TEXT + }, { + sequelize, + modelName: 'playlist' + }) + + const { library, user } = sequelize.models + library.hasMany(Playlist) + Playlist.belongsTo(library) + + user.hasMany(Playlist, { + onDelete: 'CASCADE' + }) + Playlist.belongsTo(user) + + Playlist.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + + for (const instance of findResult) { + if (instance.playlistMediaItems?.length) { + instance.playlistMediaItems = instance.playlistMediaItems.map(pmi => { + if (pmi.mediaItemType === 'book' && pmi.book !== undefined) { + pmi.mediaItem = pmi.book + pmi.dataValues.mediaItem = pmi.dataValues.book + } else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) { + pmi.mediaItem = pmi.podcastEpisode + pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode + } + // To prevent mistakes: + delete pmi.book + delete pmi.dataValues.book + delete pmi.podcastEpisode + delete pmi.dataValues.podcastEpisode + return pmi + }) + } + + } + }) + } +} + +module.exports = Playlist \ No newline at end of file diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js index 915739e6..8decc7ed 100644 --- a/server/models/PlaylistMediaItem.js +++ b/server/models/PlaylistMediaItem.js @@ -1,84 +1,105 @@ const { DataTypes, Model } = require('sequelize') -module.exports = (sequelize) => { - class PlaylistMediaItem extends Model { - static removeByIds(playlistId, mediaItemId) { - return this.destroy({ - where: { - playlistId, - mediaItemId - } - }) - } +class PlaylistMediaItem extends Model { + constructor(values, options) { + super(values, options) - getMediaItem(options) { - if (!this.mediaItemType) return Promise.resolve(null) - const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` - return this[mixinMethodName](options) - } + /** @type {UUIDV4} */ + this.id + /** @type {UUIDV4} */ + this.mediaItemId + /** @type {string} */ + this.mediaItemType + /** @type {number} */ + this.order + /** @type {UUIDV4} */ + this.playlistId + /** @type {Date} */ + this.createdAt } - PlaylistMediaItem.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - mediaItemId: DataTypes.UUIDV4, - mediaItemType: DataTypes.STRING, - order: DataTypes.INTEGER - }, { - sequelize, - timestamps: true, - updatedAt: false, - modelName: 'playlistMediaItem' - }) - - const { book, podcastEpisode, playlist } = sequelize.models - - book.hasMany(PlaylistMediaItem, { - foreignKey: 'mediaItemId', - constraints: false, - scope: { - mediaItemType: 'book' - } - }) - PlaylistMediaItem.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) - - podcastEpisode.hasOne(PlaylistMediaItem, { - foreignKey: 'mediaItemId', - constraints: false, - scope: { - mediaItemType: 'podcastEpisode' - } - }) - PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) - - PlaylistMediaItem.addHook('afterFind', findResult => { - if (!findResult) return - - if (!Array.isArray(findResult)) findResult = [findResult] - - for (const instance of findResult) { - if (instance.mediaItemType === 'book' && instance.book !== undefined) { - instance.mediaItem = instance.book - instance.dataValues.mediaItem = instance.dataValues.book - } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { - instance.mediaItem = instance.podcastEpisode - instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + static removeByIds(playlistId, mediaItemId) { + return this.destroy({ + where: { + playlistId, + mediaItemId } - // To prevent mistakes: - delete instance.book - delete instance.dataValues.book - delete instance.podcastEpisode - delete instance.dataValues.podcastEpisode - } - }) + }) + } - playlist.hasMany(PlaylistMediaItem, { - onDelete: 'CASCADE' - }) - PlaylistMediaItem.belongsTo(playlist) + getMediaItem(options) { + if (!this.mediaItemType) return Promise.resolve(null) + const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}` + return this[mixinMethodName](options) + } - return PlaylistMediaItem -} \ No newline at end of file + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + mediaItemId: DataTypes.UUIDV4, + mediaItemType: DataTypes.STRING, + order: DataTypes.INTEGER + }, { + sequelize, + timestamps: true, + updatedAt: false, + modelName: 'playlistMediaItem' + }) + + const { book, podcastEpisode, playlist } = sequelize.models + + book.hasMany(PlaylistMediaItem, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'book' + } + }) + PlaylistMediaItem.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) + + podcastEpisode.hasOne(PlaylistMediaItem, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'podcastEpisode' + } + }) + PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + + PlaylistMediaItem.addHook('afterFind', findResult => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + + for (const instance of findResult) { + if (instance.mediaItemType === 'book' && instance.book !== undefined) { + instance.mediaItem = instance.book + instance.dataValues.mediaItem = instance.dataValues.book + } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { + instance.mediaItem = instance.podcastEpisode + instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + } + // To prevent mistakes: + delete instance.book + delete instance.dataValues.book + delete instance.podcastEpisode + delete instance.dataValues.podcastEpisode + } + }) + + playlist.hasMany(PlaylistMediaItem, { + onDelete: 'CASCADE' + }) + PlaylistMediaItem.belongsTo(playlist) + } +} + +module.exports = PlaylistMediaItem diff --git a/server/models/Podcast.js b/server/models/Podcast.js index f9d055c0..5f75ee3c 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -1,100 +1,155 @@ const { DataTypes, Model } = require('sequelize') -module.exports = (sequelize) => { - class Podcast extends Model { - static getOldPodcast(libraryItemExpanded) { - const podcastExpanded = libraryItemExpanded.media - const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index) - return { - id: podcastExpanded.id, - libraryItemId: libraryItemExpanded.id, - metadata: { - title: podcastExpanded.title, - author: podcastExpanded.author, - description: podcastExpanded.description, - releaseDate: podcastExpanded.releaseDate, - genres: podcastExpanded.genres, - feedUrl: podcastExpanded.feedURL, - imageUrl: podcastExpanded.imageURL, - itunesPageUrl: podcastExpanded.itunesPageURL, - itunesId: podcastExpanded.itunesId, - itunesArtistId: podcastExpanded.itunesArtistId, - explicit: podcastExpanded.explicit, - language: podcastExpanded.language, - type: podcastExpanded.podcastType - }, - coverPath: podcastExpanded.coverPath, - tags: podcastExpanded.tags, - episodes: podcastEpisodes || [], - autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes, - autoDownloadSchedule: podcastExpanded.autoDownloadSchedule, - lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null, - maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep, - maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload - } - } +class Podcast extends Model { + constructor(values, options) { + super(values, options) - static getFromOld(oldPodcast) { - const oldPodcastMetadata = oldPodcast.metadata - return { - id: oldPodcast.id, - title: oldPodcastMetadata.title, - titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix, - author: oldPodcastMetadata.author, - releaseDate: oldPodcastMetadata.releaseDate, - feedURL: oldPodcastMetadata.feedUrl, - imageURL: oldPodcastMetadata.imageUrl, - description: oldPodcastMetadata.description, - itunesPageURL: oldPodcastMetadata.itunesPageUrl, - itunesId: oldPodcastMetadata.itunesId, - itunesArtistId: oldPodcastMetadata.itunesArtistId, - language: oldPodcastMetadata.language, - podcastType: oldPodcastMetadata.type, - explicit: !!oldPodcastMetadata.explicit, - autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes, - autoDownloadSchedule: oldPodcast.autoDownloadSchedule, - lastEpisodeCheck: oldPodcast.lastEpisodeCheck, - maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep, - maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload, - coverPath: oldPodcast.coverPath, - tags: oldPodcast.tags, - genres: oldPodcastMetadata.genres - } + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.title + /** @type {string} */ + this.titleIgnorePrefix + /** @type {string} */ + this.author + /** @type {string} */ + this.releaseDate + /** @type {string} */ + this.feedURL + /** @type {string} */ + this.imageURL + /** @type {string} */ + this.description + /** @type {string} */ + this.itunesPageURL + /** @type {string} */ + this.itunesId + /** @type {string} */ + this.itunesArtistId + /** @type {string} */ + this.language + /** @type {string} */ + this.podcastType + /** @type {boolean} */ + this.explicit + /** @type {boolean} */ + this.autoDownloadEpisodes + /** @type {string} */ + this.autoDownloadSchedule + /** @type {Date} */ + this.lastEpisodeCheck + /** @type {number} */ + this.maxEpisodesToKeep + /** @type {string} */ + this.coverPath + /** @type {Object} */ + this.tags + /** @type {Object} */ + this.genres + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } + + static getOldPodcast(libraryItemExpanded) { + const podcastExpanded = libraryItemExpanded.media + const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index) + return { + id: podcastExpanded.id, + libraryItemId: libraryItemExpanded.id, + metadata: { + title: podcastExpanded.title, + author: podcastExpanded.author, + description: podcastExpanded.description, + releaseDate: podcastExpanded.releaseDate, + genres: podcastExpanded.genres, + feedUrl: podcastExpanded.feedURL, + imageUrl: podcastExpanded.imageURL, + itunesPageUrl: podcastExpanded.itunesPageURL, + itunesId: podcastExpanded.itunesId, + itunesArtistId: podcastExpanded.itunesArtistId, + explicit: podcastExpanded.explicit, + language: podcastExpanded.language, + type: podcastExpanded.podcastType + }, + coverPath: podcastExpanded.coverPath, + tags: podcastExpanded.tags, + episodes: podcastEpisodes || [], + autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes, + autoDownloadSchedule: podcastExpanded.autoDownloadSchedule, + lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null, + maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep, + maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload } } - Podcast.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - title: DataTypes.STRING, - titleIgnorePrefix: DataTypes.STRING, - author: DataTypes.STRING, - releaseDate: DataTypes.STRING, - feedURL: DataTypes.STRING, - imageURL: DataTypes.STRING, - description: DataTypes.TEXT, - itunesPageURL: DataTypes.STRING, - itunesId: DataTypes.STRING, - itunesArtistId: DataTypes.STRING, - language: DataTypes.STRING, - podcastType: DataTypes.STRING, - explicit: DataTypes.BOOLEAN, + static getFromOld(oldPodcast) { + const oldPodcastMetadata = oldPodcast.metadata + return { + id: oldPodcast.id, + title: oldPodcastMetadata.title, + titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix, + author: oldPodcastMetadata.author, + releaseDate: oldPodcastMetadata.releaseDate, + feedURL: oldPodcastMetadata.feedUrl, + imageURL: oldPodcastMetadata.imageUrl, + description: oldPodcastMetadata.description, + itunesPageURL: oldPodcastMetadata.itunesPageUrl, + itunesId: oldPodcastMetadata.itunesId, + itunesArtistId: oldPodcastMetadata.itunesArtistId, + language: oldPodcastMetadata.language, + podcastType: oldPodcastMetadata.type, + explicit: !!oldPodcastMetadata.explicit, + autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes, + autoDownloadSchedule: oldPodcast.autoDownloadSchedule, + lastEpisodeCheck: oldPodcast.lastEpisodeCheck, + maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep, + maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload, + coverPath: oldPodcast.coverPath, + tags: oldPodcast.tags, + genres: oldPodcastMetadata.genres + } + } - autoDownloadEpisodes: DataTypes.BOOLEAN, - autoDownloadSchedule: DataTypes.STRING, - lastEpisodeCheck: DataTypes.DATE, - maxEpisodesToKeep: DataTypes.INTEGER, - maxNewEpisodesToDownload: DataTypes.INTEGER, - coverPath: DataTypes.STRING, - tags: DataTypes.JSON, - genres: DataTypes.JSON - }, { - sequelize, - modelName: 'podcast' - }) + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: DataTypes.STRING, + titleIgnorePrefix: DataTypes.STRING, + author: DataTypes.STRING, + releaseDate: DataTypes.STRING, + feedURL: DataTypes.STRING, + imageURL: DataTypes.STRING, + description: DataTypes.TEXT, + itunesPageURL: DataTypes.STRING, + itunesId: DataTypes.STRING, + itunesArtistId: DataTypes.STRING, + language: DataTypes.STRING, + podcastType: DataTypes.STRING, + explicit: DataTypes.BOOLEAN, - return Podcast -} \ No newline at end of file + autoDownloadEpisodes: DataTypes.BOOLEAN, + autoDownloadSchedule: DataTypes.STRING, + lastEpisodeCheck: DataTypes.DATE, + maxEpisodesToKeep: DataTypes.INTEGER, + maxNewEpisodesToDownload: DataTypes.INTEGER, + coverPath: DataTypes.STRING, + tags: DataTypes.JSON, + genres: DataTypes.JSON + }, { + sequelize, + modelName: 'podcast' + }) + } +} + +module.exports = Podcast \ No newline at end of file diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 8f2d6b79..6aff7866 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -1,102 +1,149 @@ const { DataTypes, Model } = require('sequelize') -module.exports = (sequelize) => { - class PodcastEpisode extends Model { - getOldPodcastEpisode(libraryItemId = null) { - let enclosure = null - if (this.enclosureURL) { - enclosure = { - url: this.enclosureURL, - type: this.enclosureType, - length: this.enclosureSize !== null ? String(this.enclosureSize) : null - } - } - return { - libraryItemId: libraryItemId || null, - podcastId: this.podcastId, - id: this.id, - oldEpisodeId: this.extraData?.oldEpisodeId || null, - index: this.index, - season: this.season, - episode: this.episode, - episodeType: this.episodeType, - title: this.title, - subtitle: this.subtitle, - description: this.description, - enclosure, - pubDate: this.pubDate, - chapters: this.chapters, - audioFile: this.audioFile, - publishedAt: this.publishedAt?.valueOf() || null, - addedAt: this.createdAt.valueOf(), - updatedAt: this.updatedAt.valueOf() +class PodcastEpisode extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {number} */ + this.index + /** @type {string} */ + this.season + /** @type {string} */ + this.episode + /** @type {string} */ + this.episodeType + /** @type {string} */ + this.title + /** @type {string} */ + this.subtitle + /** @type {string} */ + this.description + /** @type {string} */ + this.pubDate + /** @type {string} */ + this.enclosureURL + /** @type {BigInt} */ + this.enclosureSize + /** @type {string} */ + this.enclosureType + /** @type {Date} */ + this.publishedAt + /** @type {Object} */ + this.audioFile + /** @type {Object} */ + this.chapters + /** @type {Object} */ + this.extraData + /** @type {UUIDV4} */ + this.podcastId + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } + + getOldPodcastEpisode(libraryItemId = null) { + let enclosure = null + if (this.enclosureURL) { + enclosure = { + url: this.enclosureURL, + type: this.enclosureType, + length: this.enclosureSize !== null ? String(this.enclosureSize) : null } } - - static createFromOld(oldEpisode) { - const podcastEpisode = this.getFromOld(oldEpisode) - return this.create(podcastEpisode) - } - - static getFromOld(oldEpisode) { - const extraData = {} - if (oldEpisode.oldEpisodeId) { - extraData.oldEpisodeId = oldEpisode.oldEpisodeId - } - return { - id: oldEpisode.id, - index: oldEpisode.index, - season: oldEpisode.season, - episode: oldEpisode.episode, - episodeType: oldEpisode.episodeType, - title: oldEpisode.title, - subtitle: oldEpisode.subtitle, - description: oldEpisode.description, - pubDate: oldEpisode.pubDate, - enclosureURL: oldEpisode.enclosure?.url || null, - enclosureSize: oldEpisode.enclosure?.length || null, - enclosureType: oldEpisode.enclosure?.type || null, - publishedAt: oldEpisode.publishedAt, - podcastId: oldEpisode.podcastId, - audioFile: oldEpisode.audioFile?.toJSON() || null, - chapters: oldEpisode.chapters, - extraData - } + return { + libraryItemId: libraryItemId || null, + podcastId: this.podcastId, + id: this.id, + oldEpisodeId: this.extraData?.oldEpisodeId || null, + index: this.index, + season: this.season, + episode: this.episode, + episodeType: this.episodeType, + title: this.title, + subtitle: this.subtitle, + description: this.description, + enclosure, + pubDate: this.pubDate, + chapters: this.chapters, + audioFile: this.audioFile, + publishedAt: this.publishedAt?.valueOf() || null, + addedAt: this.createdAt.valueOf(), + updatedAt: this.updatedAt.valueOf() } } - PodcastEpisode.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - index: DataTypes.INTEGER, - season: DataTypes.STRING, - episode: DataTypes.STRING, - episodeType: DataTypes.STRING, - title: DataTypes.STRING, - subtitle: DataTypes.STRING(1000), - description: DataTypes.TEXT, - pubDate: DataTypes.STRING, - enclosureURL: DataTypes.STRING, - enclosureSize: DataTypes.BIGINT, - enclosureType: DataTypes.STRING, - publishedAt: DataTypes.DATE, + static createFromOld(oldEpisode) { + const podcastEpisode = this.getFromOld(oldEpisode) + return this.create(podcastEpisode) + } - audioFile: DataTypes.JSON, - chapters: DataTypes.JSON, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'podcastEpisode' - }) + static getFromOld(oldEpisode) { + const extraData = {} + if (oldEpisode.oldEpisodeId) { + extraData.oldEpisodeId = oldEpisode.oldEpisodeId + } + return { + id: oldEpisode.id, + index: oldEpisode.index, + season: oldEpisode.season, + episode: oldEpisode.episode, + episodeType: oldEpisode.episodeType, + title: oldEpisode.title, + subtitle: oldEpisode.subtitle, + description: oldEpisode.description, + pubDate: oldEpisode.pubDate, + enclosureURL: oldEpisode.enclosure?.url || null, + enclosureSize: oldEpisode.enclosure?.length || null, + enclosureType: oldEpisode.enclosure?.type || null, + publishedAt: oldEpisode.publishedAt, + podcastId: oldEpisode.podcastId, + audioFile: oldEpisode.audioFile?.toJSON() || null, + chapters: oldEpisode.chapters, + extraData + } + } - const { podcast } = sequelize.models - podcast.hasMany(PodcastEpisode, { - onDelete: 'CASCADE' - }) - PodcastEpisode.belongsTo(podcast) + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + index: DataTypes.INTEGER, + season: DataTypes.STRING, + episode: DataTypes.STRING, + episodeType: DataTypes.STRING, + title: DataTypes.STRING, + subtitle: DataTypes.STRING(1000), + description: DataTypes.TEXT, + pubDate: DataTypes.STRING, + enclosureURL: DataTypes.STRING, + enclosureSize: DataTypes.BIGINT, + enclosureType: DataTypes.STRING, + publishedAt: DataTypes.DATE, - return PodcastEpisode -} \ No newline at end of file + audioFile: DataTypes.JSON, + chapters: DataTypes.JSON, + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'podcastEpisode' + }) + + const { podcast } = sequelize.models + podcast.hasMany(PodcastEpisode, { + onDelete: 'CASCADE' + }) + PodcastEpisode.belongsTo(podcast) + } +} + +module.exports = PodcastEpisode \ No newline at end of file diff --git a/server/models/Series.js b/server/models/Series.js index e61d5e0e..f4cdbffe 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -2,81 +2,104 @@ const { DataTypes, Model } = require('sequelize') const oldSeries = require('../objects/entities/Series') -module.exports = (sequelize) => { - class Series extends Model { - static async getAllOldSeries() { - const series = await this.findAll() - return series.map(se => se.getOldSeries()) - } +class Series extends Model { + constructor(values, options) { + super(values, options) - getOldSeries() { - return new oldSeries({ - id: this.id, - name: this.name, - description: this.description, - libraryId: this.libraryId, - addedAt: this.createdAt.valueOf(), - updatedAt: this.updatedAt.valueOf() - }) - } + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.name + /** @type {string} */ + this.nameIgnorePrefix + /** @type {string} */ + this.description + /** @type {UUIDV4} */ + this.libraryId + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } - static updateFromOld(oldSeries) { - const series = this.getFromOld(oldSeries) - return this.update(series, { - where: { - id: series.id - } - }) - } + static async getAllOldSeries() { + const series = await this.findAll() + return series.map(se => se.getOldSeries()) + } - static createFromOld(oldSeries) { - const series = this.getFromOld(oldSeries) - return this.create(series) - } + getOldSeries() { + return new oldSeries({ + id: this.id, + name: this.name, + description: this.description, + libraryId: this.libraryId, + addedAt: this.createdAt.valueOf(), + updatedAt: this.updatedAt.valueOf() + }) + } - static createBulkFromOld(oldSeriesObjs) { - const series = oldSeriesObjs.map(this.getFromOld) - return this.bulkCreate(series) - } - - static getFromOld(oldSeries) { - return { - id: oldSeries.id, - name: oldSeries.name, - nameIgnorePrefix: oldSeries.nameIgnorePrefix, - description: oldSeries.description, - libraryId: oldSeries.libraryId + static updateFromOld(oldSeries) { + const series = this.getFromOld(oldSeries) + return this.update(series, { + where: { + id: series.id } - } + }) + } - static removeById(seriesId) { - return this.destroy({ - where: { - id: seriesId - } - }) + static createFromOld(oldSeries) { + const series = this.getFromOld(oldSeries) + return this.create(series) + } + + static createBulkFromOld(oldSeriesObjs) { + const series = oldSeriesObjs.map(this.getFromOld) + return this.bulkCreate(series) + } + + static getFromOld(oldSeries) { + return { + id: oldSeries.id, + name: oldSeries.name, + nameIgnorePrefix: oldSeries.nameIgnorePrefix, + description: oldSeries.description, + libraryId: oldSeries.libraryId } } - Series.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - name: DataTypes.STRING, - nameIgnorePrefix: DataTypes.STRING, - description: DataTypes.TEXT - }, { - sequelize, - modelName: 'series' - }) + static removeById(seriesId) { + return this.destroy({ + where: { + id: seriesId + } + }) + } - const { library } = sequelize.models - library.hasMany(Series, { - onDelete: 'CASCADE' - }) - Series.belongsTo(library) + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + nameIgnorePrefix: DataTypes.STRING, + description: DataTypes.TEXT + }, { + sequelize, + modelName: 'series' + }) - return Series -} \ No newline at end of file + const { library } = sequelize.models + library.hasMany(Series, { + onDelete: 'CASCADE' + }) + Series.belongsTo(library) + } +} + +module.exports = Series \ No newline at end of file diff --git a/server/models/Setting.js b/server/models/Setting.js index 9b47c227..c3348e24 100644 --- a/server/models/Setting.js +++ b/server/models/Setting.js @@ -4,42 +4,59 @@ const oldEmailSettings = require('../objects/settings/EmailSettings') const oldServerSettings = require('../objects/settings/ServerSettings') const oldNotificationSettings = require('../objects/settings/NotificationSettings') -module.exports = (sequelize) => { - class Setting extends Model { - static async getOldSettings() { - const settings = (await this.findAll()).map(se => se.value) +class Setting extends Model { + constructor(values, options) { + super(values, options) + + /** @type {string} */ + this.key + /** @type {Object} */ + this.value + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } + + static async getOldSettings() { + const settings = (await this.findAll()).map(se => se.value) - const emailSettingsJson = settings.find(se => se.id === 'email-settings') - const serverSettingsJson = settings.find(se => se.id === 'server-settings') - const notificationSettingsJson = settings.find(se => se.id === 'notification-settings') + const emailSettingsJson = settings.find(se => se.id === 'email-settings') + const serverSettingsJson = settings.find(se => se.id === 'server-settings') + const notificationSettingsJson = settings.find(se => se.id === 'notification-settings') - return { - settings, - emailSettings: new oldEmailSettings(emailSettingsJson), - serverSettings: new oldServerSettings(serverSettingsJson), - notificationSettings: new oldNotificationSettings(notificationSettingsJson) - } - } - - static updateSettingObj(setting) { - return this.upsert({ - key: setting.id, - value: setting - }) + return { + settings, + emailSettings: new oldEmailSettings(emailSettingsJson), + serverSettings: new oldServerSettings(serverSettingsJson), + notificationSettings: new oldNotificationSettings(notificationSettingsJson) } } - Setting.init({ - key: { - type: DataTypes.STRING, - primaryKey: true - }, - value: DataTypes.JSON - }, { - sequelize, - modelName: 'setting' - }) + static updateSettingObj(setting) { + return this.upsert({ + key: setting.id, + value: setting + }) + } - return Setting -} \ No newline at end of file + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + key: { + type: DataTypes.STRING, + primaryKey: true + }, + value: DataTypes.JSON + }, { + sequelize, + modelName: 'setting' + }) + } +} + +module.exports = Setting \ No newline at end of file diff --git a/server/models/User.js b/server/models/User.js index 6d461110..6f457aa5 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -3,238 +3,273 @@ const { DataTypes, Model, Op } = require('sequelize') const Logger = require('../Logger') const oldUser = require('../objects/user/User') -module.exports = (sequelize) => { - class User extends Model { - /** - * Get all oldUsers - * @returns {Promise} - */ - static async getOldUsers() { - const users = await this.findAll({ - include: sequelize.models.mediaProgress - }) - return users.map(u => this.getOldUser(u)) - } +class User extends Model { + constructor(values, options) { + super(values, options) - static getOldUser(userExpanded) { - const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress()) + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.username + /** @type {string} */ + this.email + /** @type {string} */ + this.pash + /** @type {string} */ + this.type + /** @type {boolean} */ + this.isActive + /** @type {boolean} */ + this.isLocked + /** @type {Date} */ + this.lastSeen + /** @type {Object} */ + this.permissions + /** @type {Object} */ + this.bookmarks + /** @type {Object} */ + this.extraData + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } - const librariesAccessible = userExpanded.permissions?.librariesAccessible || [] - const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || [] - const permissions = userExpanded.permissions || {} - delete permissions.librariesAccessible - delete permissions.itemTagsSelected + /** + * Get all oldUsers + * @returns {Promise} + */ + static async getOldUsers() { + const users = await this.findAll({ + include: this.sequelize.models.mediaProgress + }) + return users.map(u => this.getOldUser(u)) + } - return new oldUser({ - id: userExpanded.id, - oldUserId: userExpanded.extraData?.oldUserId || null, - username: userExpanded.username, - pash: userExpanded.pash, - type: userExpanded.type, - token: userExpanded.token, - mediaProgress, - seriesHideFromContinueListening: userExpanded.extraData?.seriesHideFromContinueListening || [], - bookmarks: userExpanded.bookmarks, - isActive: userExpanded.isActive, - isLocked: userExpanded.isLocked, - lastSeen: userExpanded.lastSeen?.valueOf() || null, - createdAt: userExpanded.createdAt.valueOf(), - permissions, - librariesAccessible, - itemTagsSelected - }) - } + static getOldUser(userExpanded) { + const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress()) - static createFromOld(oldUser) { - const user = this.getFromOld(oldUser) - return this.create(user) - } + const librariesAccessible = userExpanded.permissions?.librariesAccessible || [] + const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || [] + const permissions = userExpanded.permissions || {} + delete permissions.librariesAccessible + delete permissions.itemTagsSelected - static updateFromOld(oldUser) { - const user = this.getFromOld(oldUser) - return this.update(user, { - where: { - id: user.id - } - }).then((result) => result[0] > 0).catch((error) => { - Logger.error(`[User] Failed to save user ${oldUser.id}`, error) - return false - }) - } + return new oldUser({ + id: userExpanded.id, + oldUserId: userExpanded.extraData?.oldUserId || null, + username: userExpanded.username, + pash: userExpanded.pash, + type: userExpanded.type, + token: userExpanded.token, + mediaProgress, + seriesHideFromContinueListening: userExpanded.extraData?.seriesHideFromContinueListening || [], + bookmarks: userExpanded.bookmarks, + isActive: userExpanded.isActive, + isLocked: userExpanded.isLocked, + lastSeen: userExpanded.lastSeen?.valueOf() || null, + createdAt: userExpanded.createdAt.valueOf(), + permissions, + librariesAccessible, + itemTagsSelected + }) + } - static getFromOld(oldUser) { - return { - id: oldUser.id, - username: oldUser.username, - pash: oldUser.pash || null, - type: oldUser.type || null, - token: oldUser.token || null, - isActive: !!oldUser.isActive, - lastSeen: oldUser.lastSeen || null, - extraData: { - seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [], - oldUserId: oldUser.oldUserId - }, - createdAt: oldUser.createdAt || Date.now(), - permissions: { - ...oldUser.permissions, - librariesAccessible: oldUser.librariesAccessible || [], - itemTagsSelected: oldUser.itemTagsSelected || [] - }, - bookmarks: oldUser.bookmarks + static createFromOld(oldUser) { + const user = this.getFromOld(oldUser) + return this.create(user) + } + + static updateFromOld(oldUser) { + const user = this.getFromOld(oldUser) + return this.update(user, { + where: { + id: user.id } - } + }).then((result) => result[0] > 0).catch((error) => { + Logger.error(`[User] Failed to save user ${oldUser.id}`, error) + return false + }) + } - static removeById(userId) { - return this.destroy({ - where: { - id: userId - } - }) - } - - /** - * Create root user - * @param {string} username - * @param {string} pash - * @param {Auth} auth - * @returns {oldUser} - */ - static async createRootUser(username, pash, auth) { - const userId = uuidv4() - - const token = await auth.generateAccessToken({ userId, username }) - - const newRoot = new oldUser({ - id: userId, - type: 'root', - username, - pash, - token, - isActive: true, - createdAt: Date.now() - }) - await this.createFromOld(newRoot) - return newRoot - } - - /** - * Get a user by id or by the old database id - * @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id - * @param {string} userId - * @returns {Promise} null if not found - */ - static async getUserByIdOrOldId(userId) { - if (!userId) return null - const user = await this.findOne({ - where: { - [Op.or]: [ - { - id: userId - }, - { - extraData: { - [Op.substring]: userId - } - } - ] - }, - include: sequelize.models.mediaProgress - }) - if (!user) return null - return this.getOldUser(user) - } - - /** - * Get user by username case insensitive - * @param {string} username - * @returns {Promise} returns null if not found - */ - static async getUserByUsername(username) { - if (!username) return null - const user = await this.findOne({ - where: { - username: { - [Op.like]: username - } - }, - include: sequelize.models.mediaProgress - }) - if (!user) return null - return this.getOldUser(user) - } - - /** - * Get user by id - * @param {string} userId - * @returns {Promise} returns null if not found - */ - static async getUserById(userId) { - if (!userId) return null - const user = await this.findByPk(userId, { - include: sequelize.models.mediaProgress - }) - if (!user) return null - return this.getOldUser(user) - } - - /** - * Get array of user id and username - * @returns {object[]} { id, username } - */ - static async getMinifiedUserObjects() { - const users = await this.findAll({ - attributes: ['id', 'username'] - }) - return users.map(u => { - return { - id: u.id, - username: u.username - } - }) - } - - /** - * Return true if root user exists - * @returns {boolean} - */ - static async getHasRootUser() { - const count = await this.count({ - where: { - type: 'root' - } - }) - return count > 0 + static getFromOld(oldUser) { + return { + id: oldUser.id, + username: oldUser.username, + pash: oldUser.pash || null, + type: oldUser.type || null, + token: oldUser.token || null, + isActive: !!oldUser.isActive, + lastSeen: oldUser.lastSeen || null, + extraData: { + seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [], + oldUserId: oldUser.oldUserId + }, + createdAt: oldUser.createdAt || Date.now(), + permissions: { + ...oldUser.permissions, + librariesAccessible: oldUser.librariesAccessible || [], + itemTagsSelected: oldUser.itemTagsSelected || [] + }, + bookmarks: oldUser.bookmarks } } - User.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - username: DataTypes.STRING, - email: DataTypes.STRING, - pash: DataTypes.STRING, - type: DataTypes.STRING, - token: DataTypes.STRING, - isActive: { - type: DataTypes.BOOLEAN, - defaultValue: false - }, - isLocked: { - type: DataTypes.BOOLEAN, - defaultValue: false - }, - lastSeen: DataTypes.DATE, - permissions: DataTypes.JSON, - bookmarks: DataTypes.JSON, - extraData: DataTypes.JSON - }, { - sequelize, - modelName: 'user' - }) + static removeById(userId) { + return this.destroy({ + where: { + id: userId + } + }) + } - return User -} \ No newline at end of file + /** + * Create root user + * @param {string} username + * @param {string} pash + * @param {Auth} auth + * @returns {oldUser} + */ + static async createRootUser(username, pash, auth) { + const userId = uuidv4() + + const token = await auth.generateAccessToken({ userId, username }) + + const newRoot = new oldUser({ + id: userId, + type: 'root', + username, + pash, + token, + isActive: true, + createdAt: Date.now() + }) + await this.createFromOld(newRoot) + return newRoot + } + + /** + * Get a user by id or by the old database id + * @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id + * @param {string} userId + * @returns {Promise} null if not found + */ + static async getUserByIdOrOldId(userId) { + if (!userId) return null + const user = await this.findOne({ + where: { + [Op.or]: [ + { + id: userId + }, + { + extraData: { + [Op.substring]: userId + } + } + ] + }, + include: this.sequelize.models.mediaProgress + }) + if (!user) return null + return this.getOldUser(user) + } + + /** + * Get user by username case insensitive + * @param {string} username + * @returns {Promise} returns null if not found + */ + static async getUserByUsername(username) { + if (!username) return null + const user = await this.findOne({ + where: { + username: { + [Op.like]: username + } + }, + include: this.sequelize.models.mediaProgress + }) + if (!user) return null + return this.getOldUser(user) + } + + /** + * Get user by id + * @param {string} userId + * @returns {Promise} returns null if not found + */ + static async getUserById(userId) { + if (!userId) return null + const user = await this.findByPk(userId, { + include: this.sequelize.models.mediaProgress + }) + if (!user) return null + return this.getOldUser(user) + } + + /** + * Get array of user id and username + * @returns {object[]} { id, username } + */ + static async getMinifiedUserObjects() { + const users = await this.findAll({ + attributes: ['id', 'username'] + }) + return users.map(u => { + return { + id: u.id, + username: u.username + } + }) + } + + /** + * Return true if root user exists + * @returns {boolean} + */ + static async getHasRootUser() { + const count = await this.count({ + where: { + type: 'root' + } + }) + return count > 0 + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + username: DataTypes.STRING, + email: DataTypes.STRING, + pash: DataTypes.STRING, + type: DataTypes.STRING, + token: DataTypes.STRING, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + isLocked: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + lastSeen: DataTypes.DATE, + permissions: DataTypes.JSON, + bookmarks: DataTypes.JSON, + extraData: DataTypes.JSON + }, { + sequelize, + modelName: 'user' + }) + } +} + +module.exports = User \ No newline at end of file From 1ebe8a6f4c5bcdc2533f5cb5452c660f53cf1514 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 16 Aug 2023 18:08:00 -0500 Subject: [PATCH 05/19] Update scanner to load library items from db --- server/models/LibraryItem.js | 48 +++++++++++++++++++++++++++++ server/scanner/Scanner.js | 58 ++++++++++++++++++++++++++++++------ 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index f91d7dd3..12e87448 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -486,6 +486,10 @@ class LibraryItem extends Model { } ] } + ], + order: [ + [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], + [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] ] }) if (!libraryItem) return null @@ -735,6 +739,50 @@ class LibraryItem extends Model { return (await this.count({ where: { id: libraryItemId } })) > 0 } + /** + * + * @param {WhereOptions} where + * @returns {Object} oldLibraryItem + */ + static async findOneOld(where) { + const libraryItem = await this.findOne({ + where, + include: [ + { + model: this.sequelize.models.book, + include: [ + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ] + }, + { + model: this.sequelize.models.podcast, + include: [ + { + model: this.sequelize.models.podcastEpisode + } + ] + } + ], + order: [ + [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], + [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] + ] + }) + if (!libraryItem) return null + return this.getOldLibraryItem(libraryItem) + } + getMedia(options) { if (!this.mediaType) return Promise.resolve(null) const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}` diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 5aa11ab9..dfa26924 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -1,3 +1,4 @@ +const Sequelize = require('sequelize') const fs = require('../libs/fsExtra') const Path = require('path') const Logger = require('../Logger') @@ -589,28 +590,49 @@ class Scanner { // Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item const updateGroup = { ...fileUpdateGroup } for (const itemDir in updateGroup) { - if (itemDir == fileUpdateGroup[itemDir]) continue; // Media in root path + if (itemDir == fileUpdateGroup[itemDir]) continue // Media in root path const itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/')) - if (!itemDirNestedFiles.length) continue; + if (!itemDirNestedFiles.length) continue const firstNest = itemDirNestedFiles[0].split('/').shift() const altDir = `${itemDir}/${firstNest}` const fullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), itemDir) - const childLibraryItem = Database.libraryItems.find(li => li.path !== fullPath && li.path.startsWith(fullPath)) + const childLibraryItem = await Database.models.libraryItem.findOne({ + attributes: ['id', 'path'], + where: { + path: { + [Sequelize.Op.not]: fullPath + }, + path: { + [Sequelize.Op.startsWith]: fullPath + } + } + }) if (!childLibraryItem) { continue } + const altFullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), altDir) - const altChildLibraryItem = Database.libraryItems.find(li => li.path !== altFullPath && li.path.startsWith(altFullPath)) + const altChildLibraryItem = await Database.models.libraryItem.findOne({ + attributes: ['id', 'path'], + where: { + path: { + [Sequelize.Op.not]: altFullPath + }, + path: { + [Sequelize.Op.startsWith]: altFullPath + } + } + }) if (altChildLibraryItem) { continue } delete fileUpdateGroup[itemDir] fileUpdateGroup[altDir] = itemDirNestedFiles.map((f) => f.split('/').slice(1).join('/')) - Logger.warn(`[Scanner] Some files were modified in a parent directory of a library item "${childLibraryItem.title}" - ignoring`) + Logger.warn(`[Scanner] Some files were modified in a parent directory of a library item "${childLibraryItem.path}" - ignoring`) } // Second pass: Check for new/updated/removed items @@ -619,10 +641,21 @@ class Scanner { const fullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), itemDir) const dirIno = await getIno(fullPath) + const itemDirParts = itemDir.split('/').slice(0, -1) + const potentialChildDirs = [] + for (let i = 0; i < itemDirParts.length; i++) { + potentialChildDirs.push(Path.posix.join(filePathToPOSIX(folder.fullPath), itemDir.split('/').slice(0, -1 - i).join('/'))) + } + // Check if book dir group is already an item - let existingLibraryItem = Database.libraryItems.find(li => fullPath.startsWith(li.path)) + let existingLibraryItem = await Database.models.libraryItem.findOneOld({ + path: potentialChildDirs + }) + if (!existingLibraryItem) { - existingLibraryItem = Database.libraryItems.find(li => li.ino === dirIno) + existingLibraryItem = await Database.models.libraryItem.findOneOld({ + ino: dirIno + }) if (existingLibraryItem) { Logger.debug(`[Scanner] scanFolderUpdates: Library item found by inode value=${dirIno}. "${existingLibraryItem.relPath} => ${itemDir}"`) // Update library item paths for scan and all library item paths will get updated in LibraryItem.checkScanData @@ -655,9 +688,16 @@ class Scanner { } // Check if a library item is a subdirectory of this dir - var childItem = Database.libraryItems.find(li => (li.path + '/').startsWith(fullPath + '/')) + const childItem = await Database.models.libraryItem.findOne({ + attributes: ['id', 'path'], + where: { + path: { + [Sequelize.Op.startsWith]: fullPath + } + } + }) if (childItem) { - Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`) + Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.path}" - ignoring`) itemGroupingResults[itemDir] = ScanResult.NOTHING continue } From 361732a46370255a1a23c35adde9c75c66149ad0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 17 Aug 2023 17:26:12 -0500 Subject: [PATCH 06/19] Update get User API endpoint to load media progress from db --- client/components/covers/PreviewCover.vue | 7 +++- client/pages/config/users/_id/index.vue | 20 +++------ server/controllers/UserController.js | 49 ++++++++++++++++++++++- server/routers/ApiRouter.js | 28 ------------- 4 files changed, 59 insertions(+), 45 deletions(-) diff --git a/client/components/covers/PreviewCover.vue b/client/components/covers/PreviewCover.vue index f25d655d..daf579ac 100644 --- a/client/components/covers/PreviewCover.vue +++ b/client/components/covers/PreviewCover.vue @@ -13,8 +13,8 @@
- -

Invalid Cover

+ +

Invalid Cover

@@ -58,6 +58,9 @@ export default { sizeMultiplier() { return this.width / 120 }, + invalidCoverFontSize() { + return Math.max(this.sizeMultiplier * 0.8, 0.5) + }, placeholderCoverPadding() { return 0.8 * this.sizeMultiplier }, diff --git a/client/pages/config/users/_id/index.vue b/client/pages/config/users/_id/index.vue index 3477a5be..e45ce76f 100644 --- a/client/pages/config/users/_id/index.vue +++ b/client/pages/config/users/_id/index.vue @@ -47,7 +47,7 @@

{{ $strings.HeaderSavedMediaProgress }}

- +
@@ -55,19 +55,14 @@ - +
{{ $strings.LabelItem }}
- + +
No Cover
- - +

{{ item.displayTitle || 'Unknown' }}

+

{{ item.displaySubtitle }}

{{ Math.floor(item.progress * 100) }}%

@@ -124,9 +119,6 @@ export default { mediaProgress() { return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate) }, - mediaProgressWithMedia() { - return this.mediaProgress.filter((mp) => mp.media) - }, totalListeningTime() { return this.listeningStats.totalTime || 0 }, diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 0928b46f..190792d0 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -32,13 +32,60 @@ class UserController { }) } + /** + * GET: /api/users/:id + * Get a single user toJSONForBrowser + * Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt` + * + * @param {import("express").Request} req + * @param {import("express").Response} res + */ async findOne(req, res) { if (!req.user.isAdminOrUp) { Logger.error('User other than admin attempting to get user', req.user) return res.sendStatus(403) } - res.json(this.userJsonWithItemProgressDetails(req.reqUser, !req.user.isRoot)) + // Get user media progress with associated mediaItem + const mediaProgresses = await Database.models.mediaProgress.findAll({ + where: { + userId: req.reqUser.id + }, + include: [ + { + model: Database.models.book, + attributes: ['id', 'title', 'coverPath', 'updatedAt'] + }, + { + model: Database.models.podcastEpisode, + attributes: ['id', 'title'], + include: { + model: Database.models.podcast, + attributes: ['id', 'title', 'coverPath', 'updatedAt'] + } + } + ] + }) + + const oldMediaProgresses = mediaProgresses.map(mp => { + const oldMediaProgress = mp.getOldMediaProgress() + oldMediaProgress.displayTitle = mp.mediaItem?.title + if (mp.mediaItem?.podcast) { + oldMediaProgress.displaySubtitle = mp.mediaItem.podcast?.title + oldMediaProgress.coverPath = mp.mediaItem.podcast?.coverPath + oldMediaProgress.mediaUpdatedAt = mp.mediaItem.podcast?.updatedAt + } else if (mp.mediaItem) { + oldMediaProgress.coverPath = mp.mediaItem.coverPath + oldMediaProgress.mediaUpdatedAt = mp.mediaItem.updatedAt + } + return oldMediaProgress + }) + + const userJson = req.reqUser.toJSONForBrowser(!req.user.isRoot) + + userJson.mediaProgress = oldMediaProgresses + + res.json(userJson) } async create(req, res) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 5499713a..c4046688 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -352,34 +352,6 @@ class ApiRouter { // // Helper Methods // - userJsonWithItemProgressDetails(user, hideRootToken = false) { - const json = user.toJSONForBrowser(hideRootToken) - - json.mediaProgress = json.mediaProgress.map(lip => { - const libraryItem = Database.libraryItems.find(li => li.id === lip.libraryItemId) - if (!libraryItem) { - Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.libraryItemId) - lip.media = null - } else { - if (lip.episodeId) { - const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(lip.episodeId) : null - if (!episode) { - Logger.warn(`[ApiRouter] Episode ${lip.episodeId} not found for user media progress, podcast: ${libraryItem.media.metadata.title}`) - lip.media = null - } else { - lip.media = libraryItem.media.toJSONExpanded() - lip.episode = episode.toJSON() - } - } else { - lip.media = libraryItem.media.toJSONExpanded() - } - } - return lip - }).filter(lip => !!lip) - - return json - } - /** * Remove library item and associated entities * @param {string} mediaType From 7222171c5bc753a81c4f5993f3ff07c9efa42304 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 17 Aug 2023 17:58:57 -0500 Subject: [PATCH 07/19] Update checking empty series to load from Db --- server/Server.js | 1 - server/controllers/LibraryItemController.js | 15 ++++++++- server/routers/ApiRouter.js | 37 +++++++++++++++------ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/server/Server.js b/server/Server.js index 87b8e167..678691c2 100644 --- a/server/Server.js +++ b/server/Server.js @@ -114,7 +114,6 @@ class Server { await this.backupManager.init() await this.logManager.init() - await this.apiRouter.checkRemoveEmptySeries(Database.series) // Remove empty series await this.rssFeedManager.init() const libraries = await Database.models.library.getAllOldLibraries() diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index bdfc9322..2667128b 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -135,7 +135,7 @@ class LibraryItemController { if (seriesRemoved.length) { // Check remove empty series Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries(seriesRemoved) + await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id)) } if (isPodcastAutoDownloadUpdated) { @@ -351,8 +351,21 @@ class LibraryItemController { await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) + let seriesRemoved = [] + if (libraryItem.isBook && mediaPayload.metadata?.series) { + const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id) + seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id)) + } + if (libraryItem.media.update(mediaPayload)) { Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) + + if (seriesRemoved.length) { + // Check remove empty series + Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) + await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id)) + } + await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) itemsUpdated++ diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c4046688..69179006 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -371,6 +371,7 @@ class ApiRouter { // Remove series if empty if (mediaType === 'book') { + // TODO: update filter data const bookSeries = await Database.models.bookSeries.findAll({ where: { bookId: mediaItemIds[0] @@ -440,17 +441,33 @@ class ApiRouter { }) } - async checkRemoveEmptySeries(seriesToCheck, excludeLibraryItemId = null) { - if (!seriesToCheck?.length) return + /** + * Used when a series is removed from a book + * Series is removed if it only has 1 book + * TODO: Update filter data + * @param {UUIDV4} bookId + * @param {UUIDV4[]} seriesIds + */ + async checkRemoveEmptySeries(bookId, seriesIds) { + if (!seriesIds?.length) return - for (const series of seriesToCheck) { - const otherLibraryItemsInSeries = Database.libraryItems.filter(li => li.id !== excludeLibraryItemId && li.isBook && li.media.metadata.hasSeries(series.id)) - if (!otherLibraryItemsInSeries.length) { - // Close open RSS feed for series - await this.rssFeedManager.closeFeedForEntityId(series.id) - Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`) - await Database.removeSeries(series.id) - // TODO: Socket events for series? + const bookSeries = await Database.models.bookSeries.findAll({ + where: { + bookId, + seriesId: seriesIds + }, + include: [ + { + model: Database.models.series, + include: { + model: Database.models.book + } + } + ] + }) + for (const bs of bookSeries) { + if (bs.series.books.length === 1) { + await this.removeEmptySeries(bs.series) } } } From 9d7d4c69020d6fc4b4da2948266c33ac56976217 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 18 Aug 2023 14:40:36 -0500 Subject: [PATCH 08/19] Update filterData for authors/series when added/removed --- client/layouts/default.vue | 7 ++ client/store/libraries.js | 4 ++ server/Database.js | 70 +++++++++++++++++++ server/controllers/AuthorController.js | 2 + server/controllers/LibraryController.js | 6 +- server/controllers/LibraryItemController.js | 2 +- server/models/Author.js | 9 +++ server/models/Series.js | 9 +++ server/routers/ApiRouter.js | 28 +++++++- server/scanner/Scanner.js | 14 +++- server/utils/queries/libraryFilters.js | 8 +-- .../utils/queries/libraryItemsBookFilters.js | 6 +- 12 files changed, 152 insertions(+), 13 deletions(-) diff --git a/client/layouts/default.vue b/client/layouts/default.vue index d27936e2..805fb8a1 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -343,6 +343,10 @@ export default { } this.$store.commit('libraries/removeCollection', collection) }, + seriesRemoved({ id, libraryId }) { + if (this.currentLibraryId !== libraryId) return + this.$store.commit('libraries/removeSeriesFromFilterData', id) + }, playlistAdded(playlist) { if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return this.$store.commit('libraries/addUpdateUserPlaylist', playlist) @@ -442,6 +446,9 @@ export default { this.socket.on('collection_updated', this.collectionUpdated) this.socket.on('collection_removed', this.collectionRemoved) + // Series Listeners + this.socket.on('series_removed', this.seriesRemoved) + // User Playlist Listeners this.socket.on('playlist_added', this.playlistAdded) this.socket.on('playlist_updated', this.playlistUpdated) diff --git a/client/store/libraries.js b/client/store/libraries.js index e0151626..fd8af4ae 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -234,6 +234,10 @@ export const mutations = { setNumUserPlaylists(state, numUserPlaylists) { state.numUserPlaylists = numUserPlaylists }, + removeSeriesFromFilterData(state, seriesId) { + if (!seriesId || !state.filterData) return + state.filterData.series = state.filterData.series.filter(se => se.id !== seriesId) + }, updateFilterDataWithItem(state, libraryItem) { if (!libraryItem || !state.filterData) return if (state.currentLibraryId !== libraryItem.libraryId) return diff --git a/server/Database.js b/server/Database.js index ebe6d4c7..303e2d4a 100644 --- a/server/Database.js +++ b/server/Database.js @@ -34,6 +34,16 @@ class Database { return this.sequelize?.models || {} } + /** @type {typeof import('./models/Author')} */ + get authorModel() { + return this.models.author + } + + /** @type {typeof import('./models/Series')} */ + get seriesModel() { + return this.models.series + } + async checkHasDb() { if (!await fs.pathExists(this.dbPath)) { Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`) @@ -481,6 +491,66 @@ class Database { } } } + + removeSeriesFromFilterData(libraryId, seriesId) { + if (!this.libraryFilterData[libraryId]) return + this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter(se => se.id !== seriesId) + } + + addSeriesToFilterData(libraryId, seriesName, seriesId) { + if (!this.libraryFilterData[libraryId]) return + // Check if series is already added + if (this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)) return + this.libraryFilterData[libraryId].series.push({ + id: seriesId, + name: seriesName + }) + } + + removeAuthorFromFilterData(libraryId, authorId) { + if (!this.libraryFilterData[libraryId]) return + this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter(au => au.id !== authorId) + } + + addAuthorToFilterData(libraryId, authorName, authorId) { + if (!this.libraryFilterData[libraryId]) return + // Check if author is already added + if (this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)) return + this.libraryFilterData[libraryId].authors.push({ + id: authorId, + name: authorName + }) + } + + /** + * Used when updating items to make sure author id exists + * If library filter data is set then use that for check + * otherwise lookup in db + * @param {string} libraryId + * @param {string} authorId + * @returns {Promise} + */ + async checkAuthorExists(libraryId, authorId) { + if (!this.libraryFilterData[libraryId]) { + return this.authorModel.checkExistsById(authorId) + } + return this.libraryFilterData[libraryId].authors.some(au => au.id === authorId) + } + + /** + * Used when updating items to make sure series id exists + * If library filter data is set then use that for check + * otherwise lookup in db + * @param {string} libraryId + * @param {string} seriesId + * @returns {Promise} + */ + async checkSeriesExists(libraryId, seriesId) { + if (!this.libraryFilterData[libraryId]) { + return this.seriesModel.checkExistsById(seriesId) + } + return this.libraryFilterData[libraryId].series.some(se => se.id === seriesId) + } } module.exports = new Database() \ No newline at end of file diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 5133d1cb..56cb59a9 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -113,6 +113,8 @@ class AuthorController { // Remove old author await Database.removeAuthor(req.author.id) SocketAuthority.emitter('author_removed', req.author.toJSON()) + // Update filter data + Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id) // Send updated num books for merged author const numBooks = await Database.models.libraryItem.getForAuthor(existingAuthor).length diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 70c63708..97696243 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -859,12 +859,12 @@ class LibraryController { /** * GET: /api/libraries/:id/authors * Get authors for library - * @param {*} req - * @param {*} res + * @param {import('express').Request} req + * @param {import('express').Response} res */ async getAuthors(req, res) { const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(req.user) - const authors = await Database.models.author.findAll({ + const authors = await Database.authorModel.findAll({ where: { libraryId: req.library.id }, diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 2667128b..d5fa20bc 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -124,7 +124,7 @@ class LibraryItemController { // Book specific - Get all series being removed from this item let seriesRemoved = [] if (libraryItem.isBook && mediaPayload.metadata?.series) { - const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id) + const seriesIdsInUpdate = mediaPayload.metadata.series?.map(se => se.id) || [] seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id)) } diff --git a/server/models/Author.js b/server/models/Author.js index 88dc3eea..9eeda5bf 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -83,6 +83,15 @@ class Author extends Model { }) } + /** + * Check if author exists + * @param {string} authorId + * @returns {Promise} + */ + static async checkExistsById(authorId) { + return (await this.count({ where: { id: authorId } })) > 0 + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/models/Series.js b/server/models/Series.js index f4cdbffe..243be391 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -75,6 +75,15 @@ class Series extends Model { }) } + /** + * Check if series exists + * @param {string} seriesId + * @returns {Promise} + */ + static async checkExistsById(seriesId) { + return (await this.count({ where: { id: seriesId } })) > 0 + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 69179006..e1287dcd 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -472,10 +472,20 @@ class ApiRouter { } } + /** + * Remove an empty series & close an open RSS feed + * @param {import('../models/Series')} series + */ async removeEmptySeries(series) { await this.rssFeedManager.closeFeedForEntityId(series.id) Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`) await Database.removeSeries(series.id) + // Remove series from library filter data + Database.removeSeriesFromFilterData(series.libraryId, series.id) + SocketAuthority.emitter('series_removed', { + id: series.id, + libraryId: series.libraryId + }) } async getUserListeningSessionsHelper(userId) { @@ -546,7 +556,7 @@ class ApiRouter { const mediaMetadata = mediaPayload.metadata // Create new authors if in payload - if (mediaMetadata.authors && mediaMetadata.authors.length) { + if (mediaMetadata.authors?.length) { const newAuthors = [] for (let i = 0; i < mediaMetadata.authors.length; i++) { const authorName = (mediaMetadata.authors[i].name || '').trim() @@ -555,6 +565,12 @@ class ApiRouter { continue } + // Ensure the ID for the author exists + if (mediaMetadata.authors[i].id && !(await Database.checkAuthorExists(libraryId, mediaMetadata.authors[i].id))) { + Logger.warn(`[ApiRouter] Author id "${mediaMetadata.authors[i].id}" does not exist`) + mediaMetadata.authors[i].id = null + } + if (!mediaMetadata.authors[i].id || mediaMetadata.authors[i].id.startsWith('new')) { let author = Database.authors.find(au => au.libraryId === libraryId && au.checkNameEquals(authorName)) if (!author) { @@ -562,6 +578,8 @@ class ApiRouter { author.setData(mediaMetadata.authors[i], libraryId) Logger.debug(`[ApiRouter] Created new author "${author.name}"`) newAuthors.push(author) + // Update filter data + Database.addAuthorToFilterData(libraryId, author.name, author.id) } // Update ID in original payload @@ -584,6 +602,12 @@ class ApiRouter { continue } + // Ensure the ID for the series exists + if (mediaMetadata.series[i].id && !(await Database.checkSeriesExists(libraryId, mediaMetadata.series[i].id))) { + Logger.warn(`[ApiRouter] Series id "${mediaMetadata.series[i].id}" does not exist`) + mediaMetadata.series[i].id = null + } + if (!mediaMetadata.series[i].id || mediaMetadata.series[i].id.startsWith('new')) { let seriesItem = Database.series.find(se => se.libraryId === libraryId && se.checkNameEquals(seriesName)) if (!seriesItem) { @@ -591,6 +615,8 @@ class ApiRouter { seriesItem.setData(mediaMetadata.series[i], libraryId) Logger.debug(`[ApiRouter] Created new series "${seriesItem.name}"`) newSeries.push(seriesItem) + // Update filter data + Database.addSeriesToFilterData(libraryId, seriesItem.name, seriesItem.id) } // Update ID in original payload diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index dfa26924..559cbcbd 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -486,6 +486,8 @@ class Scanner { _author = new Author() _author.setData(tempMinAuthor, libraryItem.libraryId) newAuthors.push(_author) + // Update filter data + Database.addAuthorToFilterData(libraryItem.libraryId, _author.name, _author.id) } return { @@ -502,11 +504,17 @@ class Scanner { const newSeries = [] libraryItem.media.metadata.series = libraryItem.media.metadata.series.map((tempMinSeries) => { let _series = Database.series.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name)) - if (!_series) _series = newSeries.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name)) // Check new unsaved series + if (!_series) { + // Check new unsaved series + _series = newSeries.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name)) + } + if (!_series) { // Must create new series _series = new Series() _series.setData(tempMinSeries, libraryItem.libraryId) newSeries.push(_series) + // Update filter data + Database.addSeriesToFilterData(libraryItem.libraryId, _series.name, _series.id) } return { id: _series.id, @@ -924,6 +932,8 @@ class Scanner { author.setData({ name: authorName }, libraryItem.libraryId) await Database.createAuthor(author) SocketAuthority.emitter('author_added', author.toJSON()) + // Update filter data + Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id) } authorPayload.push(author.toJSONMinimal()) } @@ -940,6 +950,8 @@ class Scanner { seriesItem = new Series() seriesItem.setData({ name: seriesMatchItem.series }, libraryItem.libraryId) await Database.createSeries(seriesItem) + // Update filter data + Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id) SocketAuthority.emitter('series_added', seriesItem.toJSON()) } seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence)) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 27e1b933..df6855a3 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -221,7 +221,7 @@ module.exports = { })) } - const { rows: series, count } = await Database.models.series.findAndCountAll({ + const { rows: series, count } = await Database.seriesModel.findAndCountAll({ where: seriesWhere, limit, offset: 0, @@ -291,7 +291,7 @@ module.exports = { async getNewestAuthors(library, user, limit) { if (library.mediaType !== 'book') return { authors: [], count: 0 } - const { rows: authors, count } = await Database.models.author.findAndCountAll({ + const { rows: authors, count } = await Database.authorModel.findAndCountAll({ where: { libraryId: library.id, createdAt: { @@ -461,7 +461,7 @@ module.exports = { if (book.language) data.languages.add(book.language) } - const series = await Database.models.series.findAll({ + const series = await Database.seriesModel.findAll({ where: { libraryId: oldLibrary.id }, @@ -469,7 +469,7 @@ module.exports = { }) series.forEach((s) => data.series.push({ id: s.id, name: s.name })) - const authors = await Database.models.author.findAll({ + const authors = await Database.authorModel.findAll({ where: { libraryId: oldLibrary.id }, diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 7495dce6..e30b201d 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -278,7 +278,7 @@ module.exports = { * @returns {object} { booksToExclude, bookSeriesToInclude } */ async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) { - const allSeries = await Database.models.series.findAll({ + const allSeries = await Database.seriesModel.findAll({ attributes: [ 'id', 'name', @@ -642,7 +642,7 @@ module.exports = { const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user) bookWhere.push(...userPermissionBookWhere.bookWhere) - const { rows: series, count } = await Database.models.series.findAndCountAll({ + const { rows: series, count } = await Database.seriesModel.findAndCountAll({ where: [ { libraryId @@ -751,7 +751,7 @@ module.exports = { const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user) // Step 1: Get the first book of every series that hasnt been started yet - const seriesNotStarted = await Database.models.series.findAll({ + const seriesNotStarted = await Database.seriesModel.findAll({ where: [ { libraryId From 4e4a976050cd8dc108366d524a11ad8e88e8ff29 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 18 Aug 2023 17:08:34 -0500 Subject: [PATCH 09/19] Update get library series api endpoint to load from db --- client/components/app/LazyBookshelf.vue | 7 + server/controllers/LibraryController.js | 35 ++- server/models/Series.js | 19 +- server/objects/entities/Series.js | 3 +- server/routers/ApiRouter.js | 1 + server/utils/queries/libraryFilters.js | 16 +- .../utils/queries/libraryItemsBookFilters.js | 2 +- server/utils/queries/seriesFilters.js | 206 ++++++++++++++++++ 8 files changed, 276 insertions(+), 13 deletions(-) create mode 100644 server/utils/queries/seriesFilters.js diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 3bc98fde..509d3f24 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -317,6 +317,8 @@ export default { // TODO: Temp use new library items API for everything except collapse sub-series if (entityPath === 'items' && !this.collapseBookSeries && !(this.filterName === 'Series' && this.collapseSeries)) { entityPath += '2' + } else if (entityPath === 'series') { + entityPath += '2' } const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' @@ -628,6 +630,11 @@ export default { return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed }, async init(bookshelf) { + if (this.entityName === 'series') { + this.booksPerFetch = 50 + } else { + this.booksPerFetch = 100 + } this.checkUpdateSearchParams() this.initSizeData(bookshelf) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 97696243..b9bb58bb 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -8,6 +8,7 @@ const Library = require('../objects/Library') const libraryHelpers = require('../utils/libraryHelpers') const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') const libraryItemFilters = require('../utils/queries/libraryItemFilters') +const seriesFilters = require('../utils/queries/seriesFilters') const { sort, createNewSortInstance } = require('../libs/fastSort') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare @@ -519,12 +520,42 @@ class LibraryController { res.sendStatus(200) } + /** + * GET: /api/libraries/:id/series2 + * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getAllSeriesForLibraryNew(req, res) { + const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v) + + const payload = { + results: [], + total: 0, + limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0, + page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0, + sortBy: req.query.sort, + sortDesc: req.query.desc === '1', + filterBy: req.query.filter, + minified: req.query.minified === '1', + include: include.join(',') + } + + const offset = payload.page * payload.limit + const { series, count } = await seriesFilters.getFilteredSeries(req.library, req.user, payload.filterBy, payload.sortBy, payload.sortDesc, include, payload.limit, offset) + + payload.total = count + payload.results = series + res.json(payload) + } + /** * GET: /api/libraries/:id/series * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open * - * @param {*} req - * @param {*} res + * @param {import('express').Request} req + * @param {import('express').Response} res */ async getAllSeriesForLibrary(req, res) { const libraryItems = req.libraryItems diff --git a/server/models/Series.js b/server/models/Series.js index 243be391..576cafce 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -100,7 +100,24 @@ class Series extends Model { description: DataTypes.TEXT }, { sequelize, - modelName: 'series' + modelName: 'series', + indexes: [ + { + fields: [{ + name: 'name', + collate: 'NOCASE' + }] + }, + { + fields: [{ + name: 'nameIgnorePrefix', + collate: 'NOCASE' + }] + }, + { + fields: ['libraryId'] + } + ] }) const { library } = sequelize.models diff --git a/server/objects/entities/Series.js b/server/objects/entities/Series.js index 31ec4ca7..59987f6f 100644 --- a/server/objects/entities/Series.js +++ b/server/objects/entities/Series.js @@ -1,5 +1,5 @@ const uuidv4 = require("uuid").v4 -const { getTitleIgnorePrefix } = require('../../utils/index') +const { getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') class Series { constructor(series) { @@ -33,6 +33,7 @@ class Series { return { id: this.id, name: this.name, + nameIgnorePrefix: getTitlePrefixAtEnd(this.name), description: this.description, addedAt: this.addedAt, updatedAt: this.updatedAt, diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e1287dcd..3ea25791 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -78,6 +78,7 @@ class ApiRouter { this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this)) this.router.delete('/libraries/:id/issues', LibraryController.middlewareNew.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this)) this.router.get('/libraries/:id/episode-downloads', LibraryController.middlewareNew.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this)) + this.router.get('/libraries/:id/series2', LibraryController.middlewareNew.bind(this), LibraryController.getAllSeriesForLibraryNew.bind(this)) this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this)) this.router.get('/libraries/:id/series/:seriesId', LibraryController.middlewareNew.bind(this), LibraryController.getSeriesForLibrary.bind(this)) this.router.get('/libraries/:id/collections', LibraryController.middlewareNew.bind(this), LibraryController.getCollectionsForLibrary.bind(this)) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index df6855a3..c2a30c18 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -41,11 +41,11 @@ module.exports = { /** * Get library items for continue listening & continue reading shelves - * @param {oldLibrary} library - * @param {oldUser} user + * @param {import('../../objects/Library')} library + * @param {import('../../objects/user/User')} user * @param {string[]} include * @param {number} limit - * @returns {object} { items:LibraryItem[], count:number } + * @returns {Promise<{ items:import('../../models/LibraryItem')[], count:number }>} */ async getMediaItemsInProgress(library, user, include, limit) { if (library.mediaType === 'book') { @@ -176,14 +176,14 @@ module.exports = { /** * Get series for recent series shelf - * @param {oldLibrary} library - * @param {oldUser} user + * @param {import('../../objects/Library')} library + * @param {import('../../objects/user/User')} user * @param {string[]} include * @param {number} limit - * @returns {object} { series:oldSeries[], count:number} + * @returns {{ series:import('../../objects/entities/Series')[], count:number}} */ async getSeriesMostRecentlyAdded(library, user, include, limit) { - if (library.mediaType !== 'book') return { series: [], count: 0 } + if (!library.isBook) return { series: [], count: 0 } const seriesIncludes = [] if (include.includes('rssfeed')) { @@ -390,7 +390,7 @@ module.exports = { /** * Get filter data used in filter menus - * @param {oldLibrary} oldLibrary + * @param {import('../../objects/Library')} oldLibrary * @returns {Promise} */ async getFilterData(oldLibrary) { diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index e30b201d..2385edf9 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -6,7 +6,7 @@ module.exports = { /** * User permissions to restrict books for explicit content & tags * @param {oldUser} user - * @returns {object} { bookWhere:Sequelize.WhereOptions, replacements:string[] } + * @returns {{ bookWhere:Sequelize.WhereOptions, replacements:object }} */ getUserPermissionBookWhereQuery(user) { const bookWhere = [] diff --git a/server/utils/queries/seriesFilters.js b/server/utils/queries/seriesFilters.js new file mode 100644 index 00000000..3e156a43 --- /dev/null +++ b/server/utils/queries/seriesFilters.js @@ -0,0 +1,206 @@ +const Sequelize = require('sequelize') +const Logger = require('../../Logger') +const Database = require('../../Database') +const libraryItemsBookFilters = require('./libraryItemsBookFilters') + +module.exports = { + decode(text) { + return Buffer.from(decodeURIComponent(text), 'base64').toString() + }, + + /** + * Get series filtered and sorted + * + * @param {import('../../objects/Library')} library + * @param {import('../../objects/user/User')} user + * @param {string} filterBy + * @param {string} sortBy + * @param {boolean} sortDesc + * @param {string[]} include + * @param {number} limit + * @param {number} offset + * @returns {Promise<{ series:object[], count:number }>} + */ + async getFilteredSeries(library, user, filterBy, sortBy, sortDesc, include, limit, offset) { + let filterValue = null + let filterGroup = null + if (filterBy) { + const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages'] + const group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) + filterGroup = group || filterBy + filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null + } + + const seriesIncludes = [] + if (include.includes('rssfeed')) { + seriesIncludes.push({ + model: Database.models.feed + }) + } + + const userPermissionBookWhere = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user) + + const seriesWhere = [ + { + libraryId: library.id + } + ] + + // Handle library setting to hide single book series + // TODO: Merge with existing query + if (library.settings.hideSingleBookSeries) { + seriesWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), { + [Sequelize.Op.gt]: 1 + })) + } + + // Handle filters + // TODO: Simplify and break-out + let attrQuery = null + if (['genres', 'tags', 'narrators'].includes(filterGroup)) { + attrQuery = `SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (SELECT count(*) FROM json_each(b.${filterGroup}) WHERE json_valid(b.${filterGroup}) AND json_each.value = :filterValue) > 0` + userPermissionBookWhere.replacements.filterValue = filterValue + } else if (filterGroup === 'authors') { + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs, bookAuthors ba WHERE bs.seriesId = series.id AND bs.bookId = b.id AND ba.bookId = b.id AND ba.authorId = :filterValue' + userPermissionBookWhere.replacements.filterValue = filterValue + } else if (filterGroup === 'publishers') { + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.publisher = :filterValue' + userPermissionBookWhere.replacements.filterValue = filterValue + } else if (filterGroup === 'languages') { + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.language = :filterValue' + userPermissionBookWhere.replacements.filterValue = filterValue + } else if (filterGroup === 'progress') { + if (filterValue === 'not-finished') { + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + } else if (filterValue === 'finished') { + const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0)) + } else if (filterValue === 'not-started') { + const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)' + seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0)) + } else if (filterValue === 'in-progress') { + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0' + } + } + + // Handle user permissions to only include series with at least 1 book + // TODO: Simplify to a single query + if (userPermissionBookWhere.bookWhere.length) { + if (!attrQuery) attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id' + + if (!user.canAccessExplicitContent) { + attrQuery += ' AND b.explicit = 0' + } + if (!user.permissions.accessAllTags && user.itemTagsSelected.length) { + if (user.permissions.selectedTagsNotAccessible) { + attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0' + } else { + attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) > 0' + } + } + } + + if (attrQuery) { + seriesWhere.push(Sequelize.where(Sequelize.literal(`(${attrQuery})`), { + [Sequelize.Op.gt]: 0 + })) + } + + const order = [] + let seriesAttributes = { + include: [] + } + + // Handle sort order + const dir = sortDesc ? 'DESC' : 'ASC' + if (sortBy === 'numBooks') { + seriesAttributes.include.push([Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks']) + order.push(['numBooks', dir]) + } else if (sortBy === 'addedAt') { + order.push(['createdAt', dir]) + } else if (sortBy === 'name') { + if (global.ServerSettings.sortingIgnorePrefix) { + order.push([Sequelize.literal('nameIgnorePrefix COLLATE NOCASE'), dir]) + } else { + order.push([Sequelize.literal('`series`.`name` COLLATE NOCASE'), dir]) + } + } else if (sortBy === 'totalDuration') { + seriesAttributes.include.push([Sequelize.literal('(SELECT SUM(b.duration) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'totalDuration']) + order.push(['totalDuration', dir]) + } else if (sortBy === 'lastBookAdded') { + seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.createdAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookAdded']) + order.push(['mostRecentBookAdded', dir]) + } else if (sortBy === 'lastBookUpdated') { + seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.updatedAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookUpdated']) + order.push(['mostRecentBookUpdated', dir]) + } + + const { rows: series, count } = await Database.seriesModel.findAndCountAll({ + where: seriesWhere, + limit, + offset, + distinct: true, + subQuery: false, + benchmark: true, + logging: (sql, timeMs) => { + console.log(`[Query] Series filter/sort. Elapsed ${timeMs}ms`) + }, + attributes: seriesAttributes, + replacements: userPermissionBookWhere.replacements, + include: [ + { + model: Database.models.bookSeries, + include: { + model: Database.models.book, + where: userPermissionBookWhere.bookWhere, + include: [ + { + model: Database.models.libraryItem + } + ] + }, + separate: true + }, + ...seriesIncludes + ], + order + }) + + // Map series to old series + const allOldSeries = [] + for (const s of series) { + const oldSeries = s.getOldSeries().toJSON() + + if (s.dataValues.totalDuration) { + oldSeries.totalDuration = s.dataValues.totalDuration + } + + if (s.feeds?.length) { + oldSeries.rssFeed = Database.models.feed.getOldFeed(s.feeds[0]).toJSONMinified() + } + + // TODO: Sort books by sequence in query + s.bookSeries.sort((a, b) => { + if (!a.sequence) return 1 + if (!b.sequence) return -1 + return a.sequence.localeCompare(b.sequence, undefined, { + numeric: true, + sensitivity: 'base' + }) + }) + oldSeries.books = s.bookSeries.map(bs => { + const libraryItem = bs.book.libraryItem.toJSON() + delete bs.book.libraryItem + libraryItem.media = bs.book + const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified() + return oldLibraryItem + }) + allOldSeries.push(oldSeries) + } + + return { + series: allOldSeries, + count + } + } +} From b334d40998325dbcaefb30503a811fdb7ea4e928 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 18 Aug 2023 17:12:15 -0500 Subject: [PATCH 10/19] Update library routes to middlewareNew --- server/routers/ApiRouter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3ea25791..8b5138b0 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -92,8 +92,8 @@ class ApiRouter { this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this)) this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.updateNarrator.bind(this)) this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.removeNarrator.bind(this)) - this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this)) - this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this)) + this.router.get('/libraries/:id/matchall', LibraryController.middlewareNew.bind(this), LibraryController.matchAll.bind(this)) + this.router.post('/libraries/:id/scan', LibraryController.middlewareNew.bind(this), LibraryController.scan.bind(this)) this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this)) this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this)) this.router.post('/libraries/order', LibraryController.reorder.bind(this)) From c77cead9ae01f0e1e72b7d03574a2160df94b55b Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 13:59:22 -0500 Subject: [PATCH 11/19] Update search endpoints to search db directly --- client/components/cards/NarratorCard.vue | 2 +- client/components/controls/GlobalSearch.vue | 2 +- server/Database.js | 15 ++ server/controllers/LibraryController.js | 82 ++------ server/routers/ApiRouter.js | 2 +- server/utils/queries/libraryItemFilters.js | 19 +- .../utils/queries/libraryItemsBookFilters.js | 199 +++++++++++++++++- .../queries/libraryItemsPodcastFilters.js | 99 +++++++++ 8 files changed, 343 insertions(+), 77 deletions(-) diff --git a/client/components/cards/NarratorCard.vue b/client/components/cards/NarratorCard.vue index 7b8848cc..e1b4840f 100644 --- a/client/components/cards/NarratorCard.vue +++ b/client/components/cards/NarratorCard.vue @@ -36,7 +36,7 @@ export default { return this.narrator?.name || '' }, numBooks() { - return this.narrator?.books?.length || 0 + return this.narrator?.numBooks || this.narrator?.books?.length || 0 }, userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] diff --git a/client/components/controls/GlobalSearch.vue b/client/components/controls/GlobalSearch.vue index a731dbcf..0c5ba41b 100644 --- a/client/components/controls/GlobalSearch.vue +++ b/client/components/controls/GlobalSearch.vue @@ -103,7 +103,7 @@ export default { return this.$store.state.libraries.currentLibraryId }, totalResults() { - return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length } }, methods: { diff --git a/server/Database.js b/server/Database.js index 303e2d4a..faec70b8 100644 --- a/server/Database.js +++ b/server/Database.js @@ -44,6 +44,21 @@ class Database { return this.models.series } + /** @type {typeof import('./models/Book')} */ + get bookModel() { + return this.models.book + } + + /** @type {typeof import('./models/Podcast')} */ + get podcastModel() { + return this.models.podcast + } + + /** @type {typeof import('./models/LibraryItem')} */ + get libraryItemModel() { + return this.models.libraryItem + } + async checkHasDb() { if (!await fs.pathExists(this.dbPath)) { Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index b9bb58bb..97f9c981 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -790,80 +790,22 @@ class LibraryController { }) } - // GET: Global library search - search(req, res) { + /** + * GET: /api/libraries/:id/search + * Search library items with query + * ?q=search + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async search(req, res) { if (!req.query.q) { return res.status(400).send('No query string') } - const libraryItems = req.libraryItems - const maxResults = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 + const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 + const query = req.query.q.trim().toLowerCase() - const itemMatches = [] - const authorMatches = {} - const narratorMatches = {} - const seriesMatches = {} - const tagMatches = {} - - libraryItems.forEach((li) => { - const queryResult = li.searchQuery(req.query.q) - if (queryResult.matchKey) { - itemMatches.push({ - libraryItem: li.toJSONExpanded(), - matchKey: queryResult.matchKey, - matchText: queryResult.matchText - }) - } - if (queryResult.series?.length) { - queryResult.series.forEach((se) => { - if (!seriesMatches[se.id]) { - const _series = Database.series.find(_se => _se.id === se.id) - if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] } - } else { - seriesMatches[se.id].books.push(li.toJSON()) - } - }) - } - if (queryResult.authors?.length) { - queryResult.authors.forEach((au) => { - if (!authorMatches[au.id]) { - const _author = Database.authors.find(_au => _au.id === au.id) - if (_author) { - authorMatches[au.id] = _author.toJSON() - authorMatches[au.id].numBooks = 1 - } - } else { - authorMatches[au.id].numBooks++ - } - }) - } - if (queryResult.tags?.length) { - queryResult.tags.forEach((tag) => { - if (!tagMatches[tag]) { - tagMatches[tag] = { name: tag, books: [li.toJSON()] } - } else { - tagMatches[tag].books.push(li.toJSON()) - } - }) - } - if (queryResult.narrators?.length) { - queryResult.narrators.forEach((narrator) => { - if (!narratorMatches[narrator]) { - narratorMatches[narrator] = { name: narrator, books: [li.toJSON()] } - } else { - narratorMatches[narrator].books.push(li.toJSON()) - } - }) - } - }) - const itemKey = req.library.mediaType - const results = { - [itemKey]: itemMatches.slice(0, maxResults), - tags: Object.values(tagMatches).slice(0, maxResults), - authors: Object.values(authorMatches).slice(0, maxResults), - series: Object.values(seriesMatches).slice(0, maxResults), - narrators: Object.values(narratorMatches).slice(0, maxResults) - } - res.json(results) + const matches = await libraryItemFilters.search(req.library, query, limit) + res.json(matches) } async stats(req, res) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 8b5138b0..c0ff10a0 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -86,7 +86,7 @@ class ApiRouter { this.router.get('/libraries/:id/personalized2', LibraryController.middlewareNew.bind(this), LibraryController.getUserPersonalizedShelves.bind(this)) this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this)) this.router.get('/libraries/:id/filterdata', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryFilterData.bind(this)) - this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this)) + this.router.get('/libraries/:id/search', LibraryController.middlewareNew.bind(this), LibraryController.search.bind(this)) this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this)) this.router.get('/libraries/:id/authors', LibraryController.middlewareNew.bind(this), LibraryController.getAuthors.bind(this)) this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this)) diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js index b20916ca..51bb4131 100644 --- a/server/utils/queries/libraryItemFilters.js +++ b/server/utils/queries/libraryItemFilters.js @@ -1,5 +1,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') +const libraryItemsBookFilters = require('./libraryItemsBookFilters') +const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters') module.exports = { /** @@ -127,7 +129,7 @@ module.exports = { /** * Get all library items that have narrators * @param {string[]} narrators - * @returns {Promise} + * @returns {Promise} */ async getAllLibraryItemsWithNarrators(narrators) { const libraryItems = [] @@ -162,5 +164,20 @@ module.exports = { libraryItems.push(libraryItem) } return libraryItems + }, + + /** + * Search library items + * @param {import('../../objects/Library')} oldLibrary + * @param {string} query + * @param {number} limit + * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}} + */ + search(oldLibrary, query, limit) { + if (oldLibrary.isBook) { + return libraryItemsBookFilters.search(oldLibrary, query, limit, 0) + } else { + return libraryItemsPodcastFilters.search(oldLibrary, query, limit, 0) + } } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 2385edf9..21ef5efa 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -918,12 +918,205 @@ module.exports = { /** * Get library items for series - * @param {oldSeries} oldSeries - * @param {[oldUser]} oldUser - * @returns {Promise} + * @param {import('../../objects/entities/Series')} oldSeries + * @param {import('../../objects/user/User')} [oldUser] + * @returns {Promise} */ async getLibraryItemsForSeries(oldSeries, oldUser) { const { libraryItems } = await this.getFilteredLibraryItems(oldSeries.libraryId, oldUser, 'series', oldSeries.id, null, null, false, [], null, null) return libraryItems.map(li => Database.models.libraryItem.getOldLibraryItem(li)) + }, + + /** + * Search books, authors, series + * @param {import('../../objects/Library')} oldLibrary + * @param {string} query + * @param {number} limit + * @param {number} offset + * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}} + */ + async search(oldLibrary, query, limit, offset) { + // Search title, subtitle, asin, isbn + const books = await Database.bookModel.findAll({ + where: { + [Sequelize.Op.or]: [ + { + title: { + [Sequelize.Op.substring]: query + } + }, + { + subtitle: { + [Sequelize.Op.substring]: query + } + }, + { + asin: { + [Sequelize.Op.substring]: query + } + }, + { + isbn: { + [Sequelize.Op.substring]: query + } + } + ] + }, + include: [ + { + model: Database.libraryItemModel, + where: { + libraryId: oldLibrary.id + } + }, + { + model: Database.models.bookSeries, + include: { + model: Database.seriesModel + }, + separate: true + }, + { + model: Database.models.bookAuthor, + include: { + model: Database.authorModel + }, + separate: true + } + ], + subQuery: false, + distinct: true, + limit, + offset + }) + + const itemMatches = [] + + for (const book of books) { + const libraryItem = book.libraryItem + delete book.libraryItem + libraryItem.media = book + + let matchText = null + let matchKey = null + for (const key of ['title', 'subtitle', 'asin', 'isbn']) { + if (book[key]?.toLowerCase().includes(query)) { + matchText = book[key] + matchKey = key + break + } + } + + if (matchKey) { + itemMatches.push({ + matchText, + matchKey, + libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded() + }) + } + } + + // Search narrators + const narratorMatches = [] + const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, { + replacements: { + query: `%${query}%`, + libraryId: oldLibrary.id, + limit, + offset + }, + raw: true + }) + for (const row of narratorResults) { + narratorMatches.push({ + name: row.value, + numBooks: row.numBooks + }) + } + + // Search tags + const tagMatches = [] + const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, { + replacements: { + query: `%${query}%`, + libraryId: oldLibrary.id, + limit, + offset + }, + raw: true + }) + for (const row of tagResults) { + tagMatches.push({ + name: row.value, + numItems: row.numItems + }) + } + + // Search series + const allSeries = await Database.seriesModel.findAll({ + where: { + name: { + [Sequelize.Op.substring]: query + }, + libraryId: oldLibrary.id + }, + include: { + separate: true, + model: Database.models.bookSeries, + include: { + model: Database.bookModel, + include: { + model: Database.libraryItemModel + } + } + }, + subQuery: false, + distinct: true, + limit, + offset + }) + const seriesMatches = [] + for (const series of allSeries) { + const books = series.bookSeries.map((bs) => { + const libraryItem = bs.book.libraryItem + libraryItem.media = bs.book + return Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSON() + }) + seriesMatches.push({ + series: series.getOldSeries().toJSON(), + books + }) + } + + // Search authors + const authors = await Database.authorModel.findAll({ + where: { + name: { + [Sequelize.Op.substring]: query + }, + libraryId: oldLibrary.id + }, + attributes: { + include: [ + [Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks'] + ] + }, + limit, + offset + }) + const authorMatches = [] + for (const author of authors) { + const oldAuthor = author.getOldAuthor().toJSON() + oldAuthor.numBooks = author.dataValues.numBooks + authorMatches.push(oldAuthor) + } + + return { + book: itemMatches, + narrators: narratorMatches, + tags: tagMatches, + series: seriesMatches, + authors: authorMatches + } } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 6462200b..7dc87a60 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -291,5 +291,104 @@ module.exports = { libraryItems, count } + }, + + /** + * Search podcasts + * @param {import('../../objects/Library')} oldLibrary + * @param {string} query + * @param {number} limit + * @param {number} offset + * @returns {{podcast:object[], tags:object[]}} + */ + async search(oldLibrary, query, limit, offset) { + // Search title, author, itunesId, itunesArtistId + const podcasts = await Database.podcastModel.findAll({ + where: { + [Sequelize.Op.or]: [ + { + title: { + [Sequelize.Op.substring]: query + } + }, + { + author: { + [Sequelize.Op.substring]: query + } + }, + { + itunesId: { + [Sequelize.Op.substring]: query + } + }, + { + itunesArtistId: { + [Sequelize.Op.substring]: query + } + } + ] + }, + include: [ + { + model: Database.libraryItemModel, + where: { + libraryId: oldLibrary.id + } + } + ], + subQuery: false, + distinct: true, + limit, + offset + }) + + const itemMatches = [] + + for (const podcast of podcasts) { + const libraryItem = podcast.libraryItem + delete podcast.libraryItem + libraryItem.media = podcast + + let matchText = null + let matchKey = null + for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) { + if (podcast[key]?.toLowerCase().includes(query)) { + matchText = podcast[key] + matchKey = key + break + } + } + + if (matchKey) { + itemMatches.push({ + matchText, + matchKey, + libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded() + }) + } + } + + // Search tags + const tagMatches = [] + const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND json_each.value LIKE :query AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, { + replacements: { + query: `%${query}%`, + libraryId: oldLibrary.id, + limit, + offset + }, + raw: true + }) + for (const row of tagResults) { + tagMatches.push({ + name: row.value, + numItems: row.numItems + }) + } + + return { + podcast: itemMatches, + tags: tagMatches + } } } \ No newline at end of file From f21d69339f476582c6fa2a92aac40898293aa2be Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 14:11:34 -0500 Subject: [PATCH 12/19] Update search query to use user permissions --- server/controllers/LibraryController.js | 2 +- server/utils/queries/libraryItemFilters.js | 7 ++- .../utils/queries/libraryItemsBookFilters.js | 59 +++++++++++-------- .../queries/libraryItemsPodcastFilters.js | 58 ++++++++++-------- 4 files changed, 71 insertions(+), 55 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 97f9c981..ea5876b3 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -804,7 +804,7 @@ class LibraryController { const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 const query = req.query.q.trim().toLowerCase() - const matches = await libraryItemFilters.search(req.library, query, limit) + const matches = await libraryItemFilters.search(req.user, req.library, query, limit) res.json(matches) } diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js index 51bb4131..e7ffe701 100644 --- a/server/utils/queries/libraryItemFilters.js +++ b/server/utils/queries/libraryItemFilters.js @@ -168,16 +168,17 @@ module.exports = { /** * Search library items + * @param {import('../../objects/user/User')} oldUser * @param {import('../../objects/Library')} oldLibrary * @param {string} query * @param {number} limit * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}} */ - search(oldLibrary, query, limit) { + search(oldUser, oldLibrary, query, limit) { if (oldLibrary.isBook) { - return libraryItemsBookFilters.search(oldLibrary, query, limit, 0) + return libraryItemsBookFilters.search(oldUser, oldLibrary, query, limit, 0) } else { - return libraryItemsPodcastFilters.search(oldLibrary, query, limit, 0) + return libraryItemsPodcastFilters.search(oldUser, oldLibrary, query, limit, 0) } } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 21ef5efa..1a7fcbdc 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -5,7 +5,7 @@ const Logger = require('../../Logger') module.exports = { /** * User permissions to restrict books for explicit content & tags - * @param {oldUser} user + * @param {import('../../objects/user/User')} user * @returns {{ bookWhere:Sequelize.WhereOptions, replacements:object }} */ getUserPermissionBookWhereQuery(user) { @@ -929,39 +929,46 @@ module.exports = { /** * Search books, authors, series + * @param {import('../../objects/user/User')} oldUser * @param {import('../../objects/Library')} oldLibrary * @param {string} query * @param {number} limit * @param {number} offset * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}} */ - async search(oldLibrary, query, limit, offset) { + async search(oldUser, oldLibrary, query, limit, offset) { + const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(oldUser) + // Search title, subtitle, asin, isbn const books = await Database.bookModel.findAll({ - where: { - [Sequelize.Op.or]: [ - { - title: { - [Sequelize.Op.substring]: query + where: [ + { + [Sequelize.Op.or]: [ + { + title: { + [Sequelize.Op.substring]: query + } + }, + { + subtitle: { + [Sequelize.Op.substring]: query + } + }, + { + asin: { + [Sequelize.Op.substring]: query + } + }, + { + isbn: { + [Sequelize.Op.substring]: query + } } - }, - { - subtitle: { - [Sequelize.Op.substring]: query - } - }, - { - asin: { - [Sequelize.Op.substring]: query - } - }, - { - isbn: { - [Sequelize.Op.substring]: query - } - } - ] - }, + ] + }, + ...userPermissionBookWhere.bookWhere + ], + replacements: userPermissionBookWhere.replacements, include: [ { model: Database.libraryItemModel, @@ -1060,11 +1067,13 @@ module.exports = { }, libraryId: oldLibrary.id }, + replacements: userPermissionBookWhere.replacements, include: { separate: true, model: Database.models.bookSeries, include: { model: Database.bookModel, + where: userPermissionBookWhere.bookWhere, include: { model: Database.libraryItemModel } diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 7dc87a60..b13125ba 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -6,8 +6,8 @@ const Logger = require('../../Logger') module.exports = { /** * User permissions to restrict podcasts for explicit content & tags - * @param {oldUser} user - * @returns {object} { podcastWhere:Sequelize.WhereOptions, replacements:string[] } + * @param {import('../../objects/user/User')} user + * @returns {{ podcastWhere:Sequelize.WhereOptions, replacements:object }} */ getUserPermissionPodcastWhereQuery(user) { const podcastWhere = [] @@ -295,39 +295,45 @@ module.exports = { /** * Search podcasts + * @param {import('../../objects/user/User')} oldUser * @param {import('../../objects/Library')} oldLibrary * @param {string} query * @param {number} limit * @param {number} offset * @returns {{podcast:object[], tags:object[]}} */ - async search(oldLibrary, query, limit, offset) { + async search(oldUser, oldLibrary, query, limit, offset) { + const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user) // Search title, author, itunesId, itunesArtistId const podcasts = await Database.podcastModel.findAll({ - where: { - [Sequelize.Op.or]: [ - { - title: { - [Sequelize.Op.substring]: query + where: [ + { + [Sequelize.Op.or]: [ + { + title: { + [Sequelize.Op.substring]: query + } + }, + { + author: { + [Sequelize.Op.substring]: query + } + }, + { + itunesId: { + [Sequelize.Op.substring]: query + } + }, + { + itunesArtistId: { + [Sequelize.Op.substring]: query + } } - }, - { - author: { - [Sequelize.Op.substring]: query - } - }, - { - itunesId: { - [Sequelize.Op.substring]: query - } - }, - { - itunesArtistId: { - [Sequelize.Op.substring]: query - } - } - ] - }, + ] + }, + ...userPermissionPodcastWhere.podcastWhere + ], + replacements: userPermissionPodcastWhere.replacements, include: [ { model: Database.libraryItemModel, From 8d451217a36a0d90ec50d30cd5febbcdfacd3428 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 14:49:06 -0500 Subject: [PATCH 13/19] Update recent-episodes API route to load from db --- server/Database.js | 10 +++ server/controllers/LibraryController.js | 35 +++-------- server/models/Podcast.js | 2 +- server/models/PodcastEpisode.js | 9 ++- server/routers/ApiRouter.js | 2 +- .../queries/libraryItemsPodcastFilters.js | 61 ++++++++++++++++++- 6 files changed, 88 insertions(+), 31 deletions(-) diff --git a/server/Database.js b/server/Database.js index faec70b8..1d9bbaa3 100644 --- a/server/Database.js +++ b/server/Database.js @@ -59,6 +59,16 @@ class Database { return this.models.libraryItem } + /** @type {typeof import('./models/PodcastEpisode')} */ + get podcastEpisodeModel() { + return this.models.podcastEpisode + } + + /** @type {typeof import('./models/MediaProgress')} */ + get mediaProgressModel() { + return this.models.mediaProgress + } + async checkHasDb() { if (!await fs.pathExists(this.dbPath)) { Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index ea5876b3..8d2cbc80 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -16,6 +16,7 @@ const naturalSort = createNewSortInstance({ const Database = require('../Database') const libraryFilters = require('../utils/queries/libraryFilters') +const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') class LibraryController { constructor() { } @@ -1024,7 +1025,12 @@ class LibraryController { Logger.info('[LibraryController] Scan complete') } - // GET: api/libraries/:id/recent-episode + /** + * GET: /api/libraries/:id/recent-episodes + * Used for latest page + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async getRecentEpisodes(req, res) { if (!req.library.isPodcast) { return res.sendStatus(404) @@ -1032,35 +1038,12 @@ class LibraryController { const payload = { episodes: [], - total: 0, limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0, page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0, } - var allUnfinishedEpisodes = [] - for (const libraryItem of req.libraryItems) { - const unfinishedEpisodes = libraryItem.media.episodes.filter(ep => { - const userProgress = req.user.getMediaProgress(libraryItem.id, ep.id) - return !userProgress || !userProgress.isFinished - }).map(_ep => { - const ep = _ep.toJSONExpanded() - ep.podcast = libraryItem.media.toJSONMinified() - ep.libraryItemId = libraryItem.id - ep.libraryId = libraryItem.libraryId - return ep - }) - allUnfinishedEpisodes.push(...unfinishedEpisodes) - } - - payload.total = allUnfinishedEpisodes.length - - allUnfinishedEpisodes = sort(allUnfinishedEpisodes).desc(ep => ep.publishedAt) - - if (payload.limit) { - var startIndex = payload.page * payload.limit - allUnfinishedEpisodes = allUnfinishedEpisodes.slice(startIndex, startIndex + payload.limit) - } - payload.episodes = allUnfinishedEpisodes + const offset = payload.page * payload.limit + payload.episodes = await libraryItemsPodcastFilters.getRecentEpisodes(req.user, req.library, payload.limit, offset) res.json(payload) } diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 5f75ee3c..06b410cf 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -54,7 +54,7 @@ class Podcast extends Model { static getOldPodcast(libraryItemExpanded) { const podcastExpanded = libraryItemExpanded.media - const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index) + const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index) return { id: podcastExpanded.id, libraryItemId: libraryItemExpanded.id, diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 6aff7866..2626ee0f 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -1,4 +1,5 @@ const { DataTypes, Model } = require('sequelize') +const oldPodcastEpisode = require('../objects/entities/PodcastEpisode') class PodcastEpisode extends Model { constructor(values, options) { @@ -44,6 +45,10 @@ class PodcastEpisode extends Model { this.updatedAt } + /** + * @param {string} libraryItemId + * @returns {oldPodcastEpisode} + */ getOldPodcastEpisode(libraryItemId = null) { let enclosure = null if (this.enclosureURL) { @@ -53,7 +58,7 @@ class PodcastEpisode extends Model { length: this.enclosureSize !== null ? String(this.enclosureSize) : null } } - return { + return new oldPodcastEpisode({ libraryItemId: libraryItemId || null, podcastId: this.podcastId, id: this.id, @@ -72,7 +77,7 @@ class PodcastEpisode extends Model { publishedAt: this.publishedAt?.valueOf() || null, addedAt: this.createdAt.valueOf(), updatedAt: this.updatedAt.valueOf() - } + }) } static createFromOld(oldEpisode) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c0ff10a0..e95cf1c3 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -94,7 +94,7 @@ class ApiRouter { this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.removeNarrator.bind(this)) this.router.get('/libraries/:id/matchall', LibraryController.middlewareNew.bind(this), LibraryController.matchAll.bind(this)) this.router.post('/libraries/:id/scan', LibraryController.middlewareNew.bind(this), LibraryController.scan.bind(this)) - this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this)) + this.router.get('/libraries/:id/recent-episodes', LibraryController.middlewareNew.bind(this), LibraryController.getRecentEpisodes.bind(this)) this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this)) this.router.post('/libraries/order', LibraryController.reorder.bind(this)) diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index b13125ba..e14718e5 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -283,7 +283,7 @@ module.exports = { const podcast = ep.podcast.toJSON() delete podcast.libraryItem libraryItem.media = podcast - libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id) + libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON() return libraryItem }) @@ -396,5 +396,64 @@ module.exports = { podcast: itemMatches, tags: tagMatches } + }, + + /** + * Most recent podcast episodes not finished + * @param {import('../../objects/user/User')} oldUser + * @param {import('../../objects/Library')} oldLibrary + * @param {number} limit + * @param {number} offset + * @returns {Promise} + */ + async getRecentEpisodes(oldUser, oldLibrary, limit, offset) { + const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(oldUser) + + const episodes = await Database.podcastEpisodeModel.findAll({ + where: { + '$mediaProgresses.isFinished$': { + [Sequelize.Op.or]: [null, false] + } + }, + replacements: userPermissionPodcastWhere.replacements, + include: [ + { + model: Database.podcastModel, + where: userPermissionPodcastWhere.podcastWhere, + required: true, + include: { + model: Database.libraryItemModel, + where: { + libraryId: oldLibrary.id + } + } + }, + { + model: Database.mediaProgressModel, + where: { + userId: oldUser.id + }, + required: false + } + ], + order: [ + ['publishedAt', 'DESC'] + ], + subQuery: false, + limit, + offset + }) + + const episodeResults = episodes.map((ep) => { + const libraryItem = ep.podcast.libraryItem + libraryItem.media = ep.podcast + const oldPodcast = Database.podcastModel.getOldPodcast(libraryItem) + const oldPodcastEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSONExpanded() + oldPodcastEpisode.podcast = oldPodcast + oldPodcastEpisode.libraryId = libraryItem.libraryId + return oldPodcastEpisode + }) + + return episodeResults } } \ No newline at end of file From ff0d6326d3ba5beb00bd7e47e1258135ccfdd6b7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 15:19:27 -0500 Subject: [PATCH 14/19] Update OPML api route to load podcasts from db --- server/controllers/LibraryController.js | 24 +++++++++++++++++-- server/managers/PodcastManager.js | 9 +++++-- server/routers/ApiRouter.js | 2 +- server/utils/generators/opmlGenerator.js | 30 ++++++++++++++---------- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 8d2cbc80..b9cf29a8 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -1047,8 +1047,28 @@ class LibraryController { res.json(payload) } - getOPMLFile(req, res) { - const opmlText = this.podcastManager.generateOPMLFileText(req.libraryItems) + /** + * GET: /api/libraries/:id/opml + * Get OPML file for a podcast library + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getOPMLFile(req, res) { + const userPermissionPodcastWhere = libraryItemsPodcastFilters.getUserPermissionPodcastWhereQuery(req.user) + const podcasts = await Database.podcastModel.findAll({ + attributes: ['id', 'feedURL', 'title', 'description', 'itunesPageURL', 'language'], + where: userPermissionPodcastWhere.podcastWhere, + replacements: userPermissionPodcastWhere.replacements, + include: { + model: Database.libraryItemModel, + attributes: ['id', 'libraryId'], + where: { + libraryId: req.library.id + } + } + }) + + const opmlText = this.podcastManager.generateOPMLFileText(podcasts) res.type('application/xml') res.send(opmlText) } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 743cb60b..b99a4c7a 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -372,8 +372,13 @@ class PodcastManager { } } - generateOPMLFileText(libraryItems) { - return opmlGenerator.generate(libraryItems) + /** + * OPML file string for podcasts in a library + * @param {import('../models/Podcast')[]} podcasts + * @returns {string} XML string + */ + generateOPMLFileText(podcasts) { + return opmlGenerator.generate(podcasts) } getDownloadQueueDetails(libraryId = null) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e95cf1c3..fff3bc91 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -95,7 +95,7 @@ class ApiRouter { this.router.get('/libraries/:id/matchall', LibraryController.middlewareNew.bind(this), LibraryController.matchAll.bind(this)) this.router.post('/libraries/:id/scan', LibraryController.middlewareNew.bind(this), LibraryController.scan.bind(this)) this.router.get('/libraries/:id/recent-episodes', LibraryController.middlewareNew.bind(this), LibraryController.getRecentEpisodes.bind(this)) - this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this)) + this.router.get('/libraries/:id/opml', LibraryController.middlewareNew.bind(this), LibraryController.getOPMLFile.bind(this)) this.router.post('/libraries/order', LibraryController.reorder.bind(this)) // diff --git a/server/utils/generators/opmlGenerator.js b/server/utils/generators/opmlGenerator.js index 60d3d450..8cc3f7fb 100644 --- a/server/utils/generators/opmlGenerator.js +++ b/server/utils/generators/opmlGenerator.js @@ -1,23 +1,29 @@ const xml = require('../../libs/xml') -module.exports.generate = (libraryItems, indent = true) => { +/** + * Generate OPML file string for podcasts in a library + * @param {import('../../models/Podcast')[]} podcasts + * @param {boolean} [indent=true] + * @returns {string} + */ +module.exports.generate = (podcasts, indent = true) => { const bodyItems = [] - libraryItems.forEach((item) => { - if (!item.media.metadata.feedUrl) return + podcasts.forEach((podcast) => { + if (!podcast.feedURL) return const feedAttributes = { type: 'rss', - text: item.media.metadata.title, - title: item.media.metadata.title, - xmlUrl: item.media.metadata.feedUrl + text: podcast.title, + title: podcast.title, + xmlUrl: podcast.feedURL } - if (item.media.metadata.description) { - feedAttributes.description = item.media.metadata.description + if (podcast.description) { + feedAttributes.description = podcast.description } - if (item.media.metadata.itunesPageUrl) { - feedAttributes.htmlUrl = item.media.metadata.itunesPageUrl + if (podcast.itunesPageUrl) { + feedAttributes.htmlUrl = podcast.itunesPageUrl } - if (item.media.metadata.language) { - feedAttributes.language = item.media.metadata.language + if (podcast.language) { + feedAttributes.language = podcast.language } bodyItems.push({ outline: { From 332078e6c12c66d44a5e17f316450a136f13aba6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 16:53:33 -0500 Subject: [PATCH 15/19] Update library stats API route to load from db --- client/components/stats/PreviewIcons.vue | 18 ++-- client/pages/config/library-stats.vue | 22 +++-- server/controllers/LibraryController.js | 54 +++++++---- server/routers/ApiRouter.js | 2 +- server/utils/queries/authorFilters.js | 69 ++++++++++++++ server/utils/queries/libraryItemFilters.js | 36 ++++++++ .../utils/queries/libraryItemsBookFilters.js | 89 ++++++++++++++----- .../queries/libraryItemsPodcastFilters.js | 70 +++++++++++++++ 8 files changed, 308 insertions(+), 52 deletions(-) create mode 100644 server/utils/queries/authorFilters.js diff --git a/client/components/stats/PreviewIcons.vue b/client/components/stats/PreviewIcons.vue index 488eae49..7bda889b 100644 --- a/client/components/stats/PreviewIcons.vue +++ b/client/components/stats/PreviewIcons.vue @@ -18,7 +18,7 @@ -
+
@@ -58,26 +58,32 @@ export default { return {} }, computed: { + currentLibraryMediaType() { + return this.$store.getters['libraries/getCurrentLibraryMediaType'] + }, + isBookLibrary() { + return this.currentLibraryMediaType === 'book' + }, user() { return this.$store.state.user.user }, totalItems() { - return this.libraryStats ? this.libraryStats.totalItems : 0 + return this.libraryStats?.totalItems || 0 }, totalAuthors() { - return this.libraryStats ? this.libraryStats.totalAuthors : 0 + return this.libraryStats?.totalAuthors || 0 }, numAudioTracks() { - return this.libraryStats ? this.libraryStats.numAudioTracks : 0 + return this.libraryStats?.numAudioTracks || 0 }, totalDuration() { - return this.libraryStats ? this.libraryStats.totalDuration : 0 + return this.libraryStats?.totalDuration || 0 }, totalHours() { return Math.round(this.totalDuration / (60 * 60)) }, totalSizePretty() { - var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0 + var totalSize = this.libraryStats?.totalSize || 0 return this.$bytesPretty(totalSize, 1) }, totalSizeNum() { diff --git a/client/pages/config/library-stats.vue b/client/pages/config/library-stats.vue index cc3dbf8e..1a95c630 100644 --- a/client/pages/config/library-stats.vue +++ b/client/pages/config/library-stats.vue @@ -22,7 +22,7 @@
-
+

{{ $strings.HeaderStatsTop10Authors }}

{{ $strings.MessageNoAuthors }}