Merge branch 'master' into feat/metadataForPlaybackSessions

This commit is contained in:
Vito0912 2025-01-07 17:01:01 +01:00 committed by GitHub
commit 121805ba39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
167 changed files with 7751 additions and 5880 deletions

View file

@ -401,75 +401,6 @@ class Database {
return this.models.setting.updateSettingObj(settings.toJSON())
}
updateBulkBooks(oldBooks) {
if (!this.sequelize) return false
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
}
createBulkCollectionBooks(collectionBooks) {
if (!this.sequelize) return false
return this.models.collectionBook.bulkCreate(collectionBooks)
}
createPlaylistMediaItem(playlistMediaItem) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.create(playlistMediaItem)
}
createBulkPlaylistMediaItems(playlistMediaItems) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
}
async createLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
}
/**
* Save metadata file and update library item
*
* @param {import('./objects/LibraryItem')} oldLibraryItem
* @returns {Promise<boolean>}
*/
async updateLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
const updated = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
// Clear library filter data cache
if (updated) {
delete this.libraryFilterData[oldLibraryItem.libraryId]
}
return updated
}
async createFeed(oldFeed) {
if (!this.sequelize) return false
await this.models.feed.fullCreateFromOld(oldFeed)
}
updateFeed(oldFeed) {
if (!this.sequelize) return false
return this.models.feed.fullUpdateFromOld(oldFeed)
}
async removeFeed(feedId) {
if (!this.sequelize) return false
await this.models.feed.removeById(feedId)
}
async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors)
}
async removeBulkBookAuthors(authorId = null, bookId = null) {
if (!this.sequelize) return false
if (!authorId && !bookId) return
await this.models.bookAuthor.removeByIds(authorId, bookId)
}
getPlaybackSessions(where = null) {
if (!this.sequelize) return false
return this.models.playbackSession.getOldPlaybackSessions(where)
@ -695,7 +626,7 @@ class Database {
/**
* Clean invalid records in database
* Series should have atleast one Book
* Book and Podcast must have an associated LibraryItem
* Book and Podcast must have an associated LibraryItem (and vice versa)
* Remove playback sessions that are 3 seconds or less
*/
async cleanDatabase() {
@ -725,6 +656,49 @@ class Database {
await book.destroy()
}
// Remove invalid LibraryItem records
const libraryItemsWithNoMedia = await this.libraryItemModel.findAll({
include: [
{
model: this.bookModel,
attributes: ['id']
},
{
model: this.podcastModel,
attributes: ['id']
}
],
where: {
'$book.id$': null,
'$podcast.id$': null
}
})
for (const libraryItem of libraryItemsWithNoMedia) {
Logger.warn(`Found libraryItem "${libraryItem.id}" with no media - removing it`)
await libraryItem.destroy()
}
const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({
include: [
{
model: this.bookModel,
attributes: ['id']
},
{
model: this.podcastEpisodeModel,
attributes: ['id']
}
],
where: {
'$book.id$': null,
'$podcastEpisode.id$': null
}
})
for (const playlistMediaItem of playlistMediaItemsWithNoMediaItem) {
Logger.warn(`Found playlistMediaItem with no book or podcastEpisode - removing it`)
await playlistMediaItem.destroy()
}
// Remove empty series
const emptySeries = await this.seriesModel.findAll({
include: {

View file

@ -6,6 +6,7 @@ const util = require('util')
const fs = require('./libs/fsExtra')
const fileUpload = require('./libs/expressFileupload')
const cookieParser = require('cookie-parser')
const axios = require('axios')
const { version } = require('../package.json')
@ -53,8 +54,36 @@ class Server {
global.RouterBasePath = ROUTER_BASE_PATH
global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1'
global.AllowIframe = process.env.ALLOW_IFRAME === '1'
global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1'
if (process.env.EXP_PROXY_SUPPORT === '1') {
// https://github.com/advplyr/audiobookshelf/pull/3754
Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`)
global.DisableSsrfRequestFilter = () => true
axios.defaults.maxRedirects = 0
axios.interceptors.response.use(
(response) => response,
(error) => {
if ([301, 302].includes(error.response?.status)) {
return axios({
...error.config,
url: error.response.headers.location
})
}
return Promise.reject(error)
}
)
} else if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
Logger.info(`[Server] SSRF Request Filter Disabled`)
global.DisableSsrfRequestFilter = () => true
} else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) {
const whitelistedUrls = process.env.SSRF_REQUEST_FILTER_WHITELIST.split(',').map((url) => url.trim())
if (whitelistedUrls.length) {
Logger.info(`[Server] SSRF Request Filter Whitelisting: ${whitelistedUrls.join(',')}`)
global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname)
}
}
if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath)
@ -72,7 +101,6 @@ class Server {
this.playbackSessionManager = new PlaybackSessionManager()
this.podcastManager = new PodcastManager()
this.audioMetadataManager = new AudioMetadataMangaer()
this.rssFeedManager = new RssFeedManager()
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager()
@ -138,7 +166,7 @@ class Server {
await ShareManager.init()
await this.backupManager.init()
await this.rssFeedManager.init()
await RssFeedManager.init()
const libraries = await Database.libraryModel.getAllWithFolders()
await this.cronManager.init(libraries)
@ -195,7 +223,7 @@ class Server {
const app = express()
app.use((req, res, next) => {
if (!global.AllowIframe) {
if (!global.ServerSettings.allowIframe) {
// Prevent clickjacking by disallowing iframes
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
}
@ -251,14 +279,17 @@ class Server {
const router = express.Router()
// if RouterBasePath is set, modify all requests to include the base path
if (global.RouterBasePath) {
app.use((req, res, next) => {
if (!req.url.startsWith(global.RouterBasePath)) {
req.url = `${global.RouterBasePath}${req.url}`
}
next()
})
}
app.use((req, res, next) => {
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
const host = req.get('host')
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
req.originalHostPrefix = `${protocol}://${host}${prefix}`
if (!urlStartsWithRouterBasePath) {
req.url = `${global.RouterBasePath}${req.url}`
}
next()
})
app.use(global.RouterBasePath, router)
app.disable('x-powered-by')
@ -289,14 +320,14 @@ class Server {
// RSS Feed temp route
router.get('/feed/:slug', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
this.rssFeedManager.getFeed(req, res)
RssFeedManager.getFeed(req, res)
})
router.get('/feed/:slug/cover*', (req, res) => {
this.rssFeedManager.getFeedCover(req, res)
RssFeedManager.getFeedCover(req, res)
})
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
this.rssFeedManager.getFeedItem(req, res)
RssFeedManager.getFeedItem(req, res)
})
// Auth routes

View file

@ -190,7 +190,9 @@ class FolderWatcher extends EventEmitter {
return
}
Logger.debug('[Watcher] File Added', path)
this.addFileUpdate(libraryId, path, 'added')
if (!this.addFileUpdate(libraryId, path, 'added')) {
return
}
if (!this.filesBeingAdded.has(path)) {
this.filesBeingAdded.add(path)
@ -261,22 +263,23 @@ class FolderWatcher extends EventEmitter {
* @param {string} libraryId
* @param {string} path
* @param {string} type
* @returns {boolean} - If file was added to pending updates
*/
addFileUpdate(libraryId, path, type) {
if (this.pendingFilePaths.includes(path)) return
if (this.pendingFilePaths.includes(path)) return false
// Get file library
const libwatcher = this.libraryWatchers.find((lw) => lw.id === libraryId)
if (!libwatcher) {
Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
return
return false
}
// Get file folder
const folder = libwatcher.libraryFolders.find((fold) => isSameOrSubPath(fold.path, path))
if (!folder) {
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
return
return false
}
const folderPath = filePathToPOSIX(folder.path)
@ -285,14 +288,14 @@ class FolderWatcher extends EventEmitter {
if (Path.extname(relPath).toLowerCase() === '.part') {
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
return
return false
}
// Ignore files/folders starting with "."
const hasDotPath = relPath.split('/').find((p) => p.startsWith('.'))
if (hasDotPath) {
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
return
return false
}
Logger.debug(`[Watcher] Modified file in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`)
@ -318,6 +321,7 @@ class FolderWatcher extends EventEmitter {
})
this.handlePendingFileUpdatesTimeout()
return true
}
/**

View file

@ -44,16 +44,21 @@ class AuthorController {
// Used on author landing page to include library items and items grouped in series
if (include.includes('items')) {
authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
const libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
if (include.includes('series')) {
const seriesMap = {}
// Group items into series
authorJson.libraryItems.forEach((li) => {
if (li.media.metadata.series) {
li.media.metadata.series.forEach((series) => {
const itemWithSeries = li.toJSONMinified()
itemWithSeries.media.metadata.series = series
libraryItems.forEach((li) => {
if (li.media.series?.length) {
li.media.series.forEach((series) => {
const itemWithSeries = li.toOldJSONMinified()
itemWithSeries.media.metadata.series = {
id: series.id,
name: series.name,
nameIgnorePrefix: series.nameIgnorePrefix,
sequence: series.bookSeries.sequence
}
if (seriesMap[series.id]) {
seriesMap[series.id].items.push(itemWithSeries)
@ -76,7 +81,7 @@ class AuthorController {
}
// Minify library items
authorJson.libraryItems = authorJson.libraryItems.map((li) => li.toJSONMinified())
authorJson.libraryItems = libraryItems.map((li) => li.toOldJSONMinified())
}
return res.json(authorJson)
@ -125,7 +130,7 @@ class AuthorController {
const bookAuthorsToCreate = []
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
const oldLibraryItems = []
const libraryItems = []
allItemsWithAuthor.forEach((libraryItem) => {
// Replace old author with merging author for each book
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
@ -134,23 +139,22 @@ class AuthorController {
name: existingAuthor.name
})
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
oldLibraryItems.push(oldLibraryItem)
libraryItems.push(libraryItem)
bookAuthorsToCreate.push({
bookId: libraryItem.media.id,
authorId: existingAuthor.id
})
})
if (oldLibraryItems.length) {
await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor
await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor
for (const libraryItem of allItemsWithAuthor) {
if (libraryItems.length) {
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
for (const libraryItem of libraryItems) {
await libraryItem.saveMetadataFile()
}
SocketAuthority.emitter(
'items_updated',
oldLibraryItems.map((li) => li.toJSONExpanded())
libraryItems.map((li) => li.toOldJSONExpanded())
)
}
@ -190,7 +194,7 @@ class AuthorController {
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
numBooksForAuthor = allItemsWithAuthor.length
const oldLibraryItems = []
const libraryItems = []
// Update author name on all books
for (const libraryItem of allItemsWithAuthor) {
libraryItem.media.authors = libraryItem.media.authors.map((au) => {
@ -199,16 +203,16 @@ class AuthorController {
}
return au
})
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
oldLibraryItems.push(oldLibraryItem)
libraryItems.push(libraryItem)
await libraryItem.saveMetadataFile()
}
if (oldLibraryItems.length) {
if (libraryItems.length) {
SocketAuthority.emitter(
'items_updated',
oldLibraryItems.map((li) => li.toJSONExpanded())
libraryItems.map((li) => li.toOldJSONExpanded())
)
}
} else {
@ -238,8 +242,18 @@ class AuthorController {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
// Load library items so that metadata file can be updated
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
allItemsWithAuthor.forEach((libraryItem) => {
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
})
await req.author.destroy()
for (const libraryItem of allItemsWithAuthor) {
await libraryItem.saveMetadataFile()
}
SocketAuthority.emitter('author_removed', req.author.toOldJSON())
// Update filter data

View file

@ -4,13 +4,18 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const Collection = require('../objects/Collection')
const RssFeedManager = require('../managers/RssFeedManager')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/Collection')} collection
*
* @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest
*/
class CollectionController {
@ -24,36 +29,71 @@ class CollectionController {
* @param {Response} res
*/
async create(req, res) {
const newCollection = new Collection()
req.body.userId = req.user.id
if (!newCollection.setData(req.body)) {
const reqBody = req.body || {}
// Validation
if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid collection data')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid collection description')
}
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
if (!libraryItemIds.length) {
return res.status(400).send('Invalid collection data. No books')
}
// Create collection record
await Database.collectionModel.createFromOld(newCollection)
// Get library items in collection
const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
// Create collectionBook records
let order = 1
const collectionBooksToAdd = []
for (const libraryItemId of newCollection.books) {
const libraryItem = libraryItemsInCollection.find((li) => li.id === libraryItemId)
if (libraryItem) {
collectionBooksToAdd.push({
collectionId: newCollection.id,
bookId: libraryItem.media.id,
order: order++
})
// Load library items
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
where: {
id: libraryItemIds,
libraryId: reqBody.libraryId,
mediaType: 'book'
}
}
if (collectionBooksToAdd.length) {
await Database.createBulkCollectionBooks(collectionBooksToAdd)
})
if (libraryItems.length !== libraryItemIds.length) {
return res.status(400).send('Invalid collection data. Invalid books')
}
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
/** @type {import('../models/Collection')} */
let newCollection = null
const transaction = await Database.sequelize.transaction()
try {
// Create collection
newCollection = await Database.collectionModel.create(
{
libraryId: reqBody.libraryId,
name: reqBody.name,
description: reqBody.description || null
},
{ transaction }
)
// Create collectionBooks
const collectionBookPayloads = libraryItemIds.map((llid, index) => {
const libraryItem = libraryItems.find((li) => li.id === llid)
return {
collectionId: newCollection.id,
bookId: libraryItem.mediaId,
order: index + 1
}
})
await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })
await transaction.commit()
} catch (error) {
await transaction.rollback()
Logger.error('[CollectionController] create:', error)
return res.status(500).send('Failed to create collection')
}
// Load books expanded
newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()
// Note: The old collection model stores expanded libraryItems in the books property
const jsonExpanded = newCollection.toOldJSONExpanded()
SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded)
}
@ -74,7 +114,7 @@ class CollectionController {
/**
* GET: /api/collections/:id
*
* @param {RequestWithUser} req
* @param {CollectionControllerRequest} req
* @param {Response} res
*/
async findOne(req, res) {
@ -93,7 +133,7 @@ class CollectionController {
* PATCH: /api/collections/:id
* Update collection
*
* @param {RequestWithUser} req
* @param {CollectionControllerRequest} req
* @param {Response} res
*/
async update(req, res) {
@ -115,6 +155,7 @@ class CollectionController {
}
// If books array is passed in then update order in collection
let collectionBooksUpdated = false
if (req.body.books?.length) {
const collectionBooks = await req.collection.getCollectionBooks({
include: {
@ -133,9 +174,15 @@ class CollectionController {
await collectionBooks[i].update({
order: i + 1
})
wasUpdated = true
collectionBooksUpdated = true
}
}
if (collectionBooksUpdated) {
req.collection.changed('updatedAt', true)
await req.collection.save()
wasUpdated = true
}
}
const jsonExpanded = await req.collection.getOldJsonExpanded()
@ -148,14 +195,16 @@ class CollectionController {
/**
* DELETE: /api/collections/:id
*
* @param {RequestWithUser} req
* @this {import('../routers/ApiRouter')}
*
* @param {CollectionControllerRequest} req
* @param {Response} res
*/
async delete(req, res) {
const jsonExpanded = await req.collection.getOldJsonExpanded()
// Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(req.collection.id)
await RssFeedManager.closeFeedForEntityId(req.collection.id)
await req.collection.destroy()
@ -168,11 +217,13 @@ class CollectionController {
* Add a single book to a collection
* Req.body { id: <library item id> }
*
* @param {RequestWithUser} req
* @param {CollectionControllerRequest} req
* @param {Response} res
*/
async addBook(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
const libraryItem = await Database.libraryItemModel.findByPk(req.body.id, {
attributes: ['libraryId', 'mediaId']
})
if (!libraryItem) {
return res.status(404).send('Book not found')
}
@ -182,14 +233,14 @@ class CollectionController {
// Check if book is already in collection
const collectionBooks = await req.collection.getCollectionBooks()
if (collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
if (collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
return res.status(400).send('Book already in collection')
}
// Create collectionBook record
await Database.collectionBookModel.create({
collectionId: req.collection.id,
bookId: libraryItem.media.id,
bookId: libraryItem.mediaId,
order: collectionBooks.length + 1
})
const jsonExpanded = await req.collection.getOldJsonExpanded()
@ -202,11 +253,13 @@ class CollectionController {
* Remove a single book from a collection. Re-order books
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId
*
* @param {RequestWithUser} req
* @param {CollectionControllerRequest} req
* @param {Response} res
*/
async removeBook(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
const libraryItem = await Database.libraryItemModel.findByPk(req.params.bookId, {
attributes: ['mediaId']
})
if (!libraryItem) {
return res.sendStatus(404)
}
@ -217,7 +270,7 @@ class CollectionController {
})
let jsonExpanded = null
const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.media.id)
const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.mediaId)
if (collectionBookToRemove) {
// Remove collection book record
await collectionBookToRemove.destroy()
@ -225,7 +278,7 @@ class CollectionController {
// Update order on collection books
let order = 1
for (const collectionBook of collectionBooks) {
if (collectionBook.bookId === libraryItem.media.id) continue
if (collectionBook.bookId === libraryItem.mediaId) continue
if (collectionBook.order !== order) {
await collectionBook.update({
order
@ -247,29 +300,31 @@ class CollectionController {
* Add multiple books to collection
* Req.body { books: <Array of library item ids> }
*
* @param {RequestWithUser} req
* @param {CollectionControllerRequest} req
* @param {Response} res
*/
async addBatch(req, res) {
// filter out invalid libraryItemIds
const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
if (!bookIdsToAdd.length) {
return res.status(500).send('Invalid request body')
return res.status(400).send('Invalid request body')
}
// Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
where: {
id: {
[Sequelize.Op.in]: bookIdsToAdd
}
},
include: {
model: Database.bookModel
id: bookIdsToAdd,
libraryId: req.collection.libraryId,
mediaType: 'book'
}
})
if (!libraryItems.length) {
return res.status(400).send('Invalid request body. No valid books')
}
// Get collection books already in collection
/** @type {import('../models/CollectionBook')[]} */
const collectionBooks = await req.collection.getCollectionBooks()
let order = collectionBooks.length + 1
@ -278,10 +333,10 @@ class CollectionController {
// Check and set new collection books to add
for (const libraryItem of libraryItems) {
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
collectionBooksToAdd.push({
collectionId: req.collection.id,
bookId: libraryItem.media.id,
bookId: libraryItem.mediaId,
order: order++
})
hasUpdated = true
@ -292,7 +347,8 @@ class CollectionController {
let jsonExpanded = null
if (hasUpdated) {
await Database.createBulkCollectionBooks(collectionBooksToAdd)
await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
jsonExpanded = await req.collection.getOldJsonExpanded()
SocketAuthority.emitter('collection_updated', jsonExpanded)
} else {
@ -306,7 +362,7 @@ class CollectionController {
* Remove multiple books from collection
* Req.body { books: <Array of library item ids> }
*
* @param {RequestWithUser} req
* @param {CollectionControllerRequest} req
* @param {Response} res
*/
async removeBatch(req, res) {
@ -319,9 +375,7 @@ class CollectionController {
// Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: {
[Sequelize.Op.in]: bookIdsToRemove
}
id: bookIdsToRemove
},
include: {
model: Database.bookModel
@ -329,6 +383,7 @@ class CollectionController {
})
// Get collection books already in collection
/** @type {import('../models/CollectionBook')[]} */
const collectionBooks = await req.collection.getCollectionBooks({
order: [['order', 'ASC']]
})

View file

@ -106,7 +106,7 @@ class EmailController {
return res.sendStatus(403)
}
const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId)
const libraryItem = await Database.libraryItemModel.getExpandedById(req.body.libraryItemId)
if (!libraryItem) {
return res.status(404).send('Library item not found')
}

View file

@ -18,6 +18,8 @@ const LibraryScanner = require('../scanner/LibraryScanner')
const Scanner = require('../scanner/Scanner')
const Database = require('../Database')
const Watcher = require('../Watcher')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const authorFilters = require('../utils/queries/authorFilters')
@ -759,8 +761,8 @@ class LibraryController {
}
if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
}
res.json(seriesJson)
@ -1143,14 +1145,14 @@ class LibraryController {
await libraryItem.media.update({
narrators: libraryItem.media.narrators
})
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
itemsUpdated.push(oldLibraryItem)
itemsUpdated.push(libraryItem)
}
if (itemsUpdated.length) {
SocketAuthority.emitter(
'items_updated',
itemsUpdated.map((li) => li.toJSONExpanded())
itemsUpdated.map((li) => li.toOldJSONExpanded())
)
}
@ -1187,14 +1189,14 @@ class LibraryController {
await libraryItem.media.update({
narrators: libraryItem.media.narrators
})
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
itemsUpdated.push(oldLibraryItem)
itemsUpdated.push(libraryItem)
}
if (itemsUpdated.length) {
SocketAuthority.emitter(
'items_updated',
itemsUpdated.map((li) => li.toJSONExpanded())
itemsUpdated.map((li) => li.toOldJSONExpanded())
)
}
@ -1215,7 +1217,7 @@ class LibraryController {
Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`)
return res.sendStatus(403)
}
Scanner.matchLibraryItems(req.library)
Scanner.matchLibraryItems(this, req.library)
res.sendStatus(200)
}

View file

@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
const AudioFileScanner = require('../scanner/AudioFileScanner')
const Scanner = require('../scanner/Scanner')
const RssFeedManager = require('../managers/RssFeedManager')
const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const ShareManager = require('../managers/ShareManager')
@ -22,6 +24,16 @@ const ShareManager = require('../managers/ShareManager')
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} LibraryItemControllerRequest
*
* @typedef RequestLibraryFileObject
* @property {import('../objects/files/LibraryFile')} libraryFile
*
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
*/
class LibraryItemController {
@ -33,23 +45,23 @@ class LibraryItemController {
* ?include=progress,rssfeed,downloads,share
* ?expanded=1
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
if (req.query.expanded == 1) {
var item = req.libraryItem.toJSONExpanded()
const item = req.libraryItem.toOldJSONExpanded()
// Include users media progress
if (includeEntities.includes('progress')) {
var episodeId = req.query.episode || null
const episodeId = req.query.episode || null
item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId)
}
if (includeEntities.includes('rssfeed')) {
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData?.toJSONMinified() || null
const feedData = await RssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData?.toOldJSONMinified() || null
}
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
@ -66,28 +78,7 @@ class LibraryItemController {
return res.json(item)
}
res.json(req.libraryItem)
}
/**
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async update(req, res) {
var libraryItem = req.libraryItem
// Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
await CacheManager.purgeCoverCache(libraryItem.id)
}
const hasUpdates = libraryItem.update(req.body)
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json(libraryItem.toJSON())
res.json(req.libraryItem.toOldJSON())
}
/**
@ -98,7 +89,7 @@ class LibraryItemController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async delete(req, res) {
@ -109,14 +100,14 @@ class LibraryItemController {
const authorIds = []
const seriesIds = []
if (req.libraryItem.isPodcast) {
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
mediaItemIds.push(...req.libraryItem.media.podcastEpisodes.map((ep) => ep.id))
} else {
mediaItemIds.push(req.libraryItem.media.id)
if (req.libraryItem.media.metadata.authors?.length) {
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
if (req.libraryItem.media.authors?.length) {
authorIds.push(...req.libraryItem.media.authors.map((au) => au.id))
}
if (req.libraryItem.media.metadata.series?.length) {
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
if (req.libraryItem.media.series?.length) {
seriesIds.push(...req.libraryItem.media.series.map((se) => se.id))
}
}
@ -153,7 +144,7 @@ class LibraryItemController {
* GET: /api/items/:id/download
* Download library item. Zip file if multiple files.
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async download(req, res) {
@ -162,7 +153,7 @@ class LibraryItemController {
return res.sendStatus(403)
}
const libraryItemPath = req.libraryItem.path
const itemTitle = req.libraryItem.media.metadata.title
const itemTitle = req.libraryItem.media.title
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
@ -192,11 +183,10 @@ class LibraryItemController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async updateMedia(req, res) {
const libraryItem = req.libraryItem
const mediaPayload = req.body
if (mediaPayload.url) {
@ -204,69 +194,79 @@ class LibraryItemController {
if (res.writableEnded || res.headersSent) return
}
// Book specific
if (libraryItem.isBook) {
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
}
// Podcast specific
let isPodcastAutoDownloadUpdated = false
if (libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
if (req.libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
isPodcastAutoDownloadUpdated = true
} else if (mediaPayload.autoDownloadSchedule !== undefined && libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
isPodcastAutoDownloadUpdated = true
}
}
// 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) || []
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
}
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
let authorsRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.authors) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
}
const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url
if (hasUpdates) {
libraryItem.updatedAt = Date.now()
if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(libraryItem)
}
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
if (authorsRemoved.length) {
// Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
}
if (seriesRemoved.length) {
if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
const seriesUpdateData = await req.libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, req.libraryItem.libraryId)
if (seriesUpdateData?.seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
await this.checkRemoveEmptySeries(seriesUpdateData.seriesRemoved.map((se) => se.id))
}
if (seriesUpdateData?.seriesAdded.length) {
// Add series to filter data
seriesUpdateData.seriesAdded.forEach((se) => {
Database.addSeriesToFilterData(req.libraryItem.libraryId, se.name, se.id)
})
}
if (seriesUpdateData?.hasUpdates) {
hasUpdates = true
}
}
if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
const authorUpdateData = await req.libraryItem.media.updateAuthorsFromRequest(authorNames, req.libraryItem.libraryId)
if (authorUpdateData?.authorsRemoved.length) {
// Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks(authorUpdateData.authorsRemoved.map((au) => au.id))
hasUpdates = true
}
if (authorUpdateData?.authorsAdded.length) {
// Add authors to filter data
authorUpdateData.authorsAdded.forEach((au) => {
Database.addAuthorToFilterData(req.libraryItem.libraryId, au.name, au.id)
})
hasUpdates = true
}
}
if (hasUpdates) {
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
await req.libraryItem.saveMetadataFile()
if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
}
Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
}
res.json({
updated: hasUpdates,
libraryItem
libraryItem: req.libraryItem.toOldJSON()
})
}
/**
* POST: /api/items/:id/cover
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
* @param {boolean} [updateAndReturnJson=true]
* @param {boolean} [updateAndReturnJson=true] - Allows the function to be used for both direct API calls and internally
*/
async uploadCover(req, res, updateAndReturnJson = true) {
if (!req.user.canUpload) {
@ -274,15 +274,13 @@ class LibraryItemController {
return res.sendStatus(403)
}
let libraryItem = req.libraryItem
let result = null
if (req.body?.url) {
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url)
result = await CoverManager.downloadCoverFromUrlNew(req.body.url, req.libraryItem.id, req.libraryItem.isFile ? null : req.libraryItem.path)
} else if (req.files?.cover) {
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
result = await CoverManager.uploadCover(libraryItem, req.files.cover)
result = await CoverManager.uploadCover(req.libraryItem, req.files.cover)
} else {
return res.status(400).send('Invalid request no file or url')
}
@ -293,9 +291,16 @@ class LibraryItemController {
return res.status(500).send('Unknown error occurred')
}
req.libraryItem.media.coverPath = result.cover
req.libraryItem.media.changed('coverPath', true)
await req.libraryItem.media.save()
if (updateAndReturnJson) {
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
// client uses updatedAt timestamp in URL to force refresh cover
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.json({
success: true,
cover: result.cover
@ -306,22 +311,28 @@ class LibraryItemController {
/**
* PATCH: /api/items/:id/cover
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async updateCover(req, res) {
const libraryItem = req.libraryItem
if (!req.body.cover) {
return res.status(400).send('Invalid request no cover path')
}
const validationResult = await CoverManager.validateCoverPath(req.body.cover, libraryItem)
const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.libraryItem)
if (validationResult.error) {
return res.status(500).send(validationResult.error)
}
if (validationResult.updated) {
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
req.libraryItem.media.coverPath = validationResult.cover
req.libraryItem.media.changed('coverPath', true)
await req.libraryItem.media.save()
// client uses updatedAt timestamp in URL to force refresh cover
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
}
res.json({
success: true,
@ -332,17 +343,22 @@ class LibraryItemController {
/**
* DELETE: /api/items/:id/cover
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async removeCover(req, res) {
var libraryItem = req.libraryItem
if (req.libraryItem.media.coverPath) {
req.libraryItem.media.coverPath = null
req.libraryItem.media.changed('coverPath', true)
await req.libraryItem.media.save()
if (libraryItem.media.coverPath) {
libraryItem.updateMediaCover('')
await CacheManager.purgeCoverCache(libraryItem.id)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
// client uses updatedAt timestamp in URL to force refresh cover
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
await CacheManager.purgeCoverCache(req.libraryItem.id)
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
}
res.sendStatus(200)
@ -351,7 +367,7 @@ class LibraryItemController {
/**
* GET: /api/items/:id/cover
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async getCover(req, res) {
@ -393,11 +409,11 @@ class LibraryItemController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
startPlaybackSession(req, res) {
if (!req.libraryItem.media.numTracks) {
if (!req.libraryItem.hasAudioTracks) {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
return res.sendStatus(404)
}
@ -410,18 +426,18 @@ class LibraryItemController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
startEpisodePlaybackSession(req, res) {
var libraryItem = req.libraryItem
if (!libraryItem.media.numTracks) {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${libraryItem.id}`)
return res.sendStatus(404)
if (!req.libraryItem.isPodcast) {
Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`)
return res.sendStatus(400)
}
var episodeId = req.params.episodeId
if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) {
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`)
const episodeId = req.params.episodeId
if (!req.libraryItem.media.podcastEpisodes.some((ep) => ep.id === episodeId)) {
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${req.libraryItem.id}`)
return res.sendStatus(404)
}
@ -431,33 +447,72 @@ class LibraryItemController {
/**
* PATCH: /api/items/:id/tracks
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async updateTracks(req, res) {
var libraryItem = req.libraryItem
var orderedFileData = req.body.orderedFileData
if (!libraryItem.media.updateAudioTracks) {
Logger.error(`[LibraryItemController] updateTracks invalid media type ${libraryItem.id}`)
return res.sendStatus(500)
const orderedFileData = req.body?.orderedFileData
if (!req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] updateTracks invalid media type ${req.libraryItem.id}`)
return res.sendStatus(400)
}
libraryItem.media.updateAudioTracks(orderedFileData)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON())
if (!Array.isArray(orderedFileData) || !orderedFileData.length) {
Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
return res.sendStatus(400)
}
// Ensure that each orderedFileData has a valid ino and is in the book audioFiles
if (orderedFileData.some((fileData) => !fileData?.ino || !req.libraryItem.media.audioFiles.some((af) => af.ino === fileData.ino))) {
Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
return res.sendStatus(400)
}
let index = 1
const updatedAudioFiles = orderedFileData.map((fileData) => {
const audioFile = req.libraryItem.media.audioFiles.find((af) => af.ino === fileData.ino)
audioFile.manuallyVerified = true
audioFile.exclude = !!fileData.exclude
if (audioFile.exclude) {
audioFile.index = -1
} else {
audioFile.index = index++
}
return audioFile
})
updatedAudioFiles.sort((a, b) => a.index - b.index)
req.libraryItem.media.audioFiles = updatedAudioFiles
req.libraryItem.media.changed('audioFiles', true)
await req.libraryItem.media.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.json(req.libraryItem.toOldJSON())
}
/**
* POST /api/items/:id/match
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async match(req, res) {
var libraryItem = req.libraryItem
const reqBody = req.body || {}
var options = req.body || {}
var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
const options = {}
const matchOptions = ['provider', 'title', 'author', 'isbn', 'asin']
for (const key of matchOptions) {
if (reqBody[key] && typeof reqBody[key] === 'string') {
options[key] = reqBody[key]
}
}
if (reqBody.overrideCover !== undefined) {
options.overrideCover = !!reqBody.overrideCover
}
if (reqBody.overrideDetails !== undefined) {
options.overrideDetails = !!reqBody.overrideDetails
}
const matchResult = await Scanner.quickMatchLibraryItem(this, req.libraryItem, options)
res.json(matchResult)
}
@ -480,11 +535,11 @@ class LibraryItemController {
const hardDelete = req.query.hard == 1 // Delete files from filesystem
const { libraryItemIds } = req.body
if (!libraryItemIds?.length) {
if (!libraryItemIds?.length || !Array.isArray(libraryItemIds)) {
return res.status(400).send('Invalid request body')
}
const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({
const itemsToDelete = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds
})
@ -495,19 +550,19 @@ class LibraryItemController {
const libraryId = itemsToDelete[0].libraryId
for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
const mediaItemIds = []
const seriesIds = []
const authorIds = []
if (libraryItem.isPodcast) {
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
mediaItemIds.push(...libraryItem.media.podcastEpisodes.map((ep) => ep.id))
} else {
mediaItemIds.push(libraryItem.media.id)
if (libraryItem.media.metadata.series?.length) {
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
if (libraryItem.media.series?.length) {
seriesIds.push(...libraryItem.media.series.map((se) => se.id))
}
if (libraryItem.media.metadata.authors?.length) {
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
if (libraryItem.media.authors?.length) {
authorIds.push(...libraryItem.media.authors.map((au) => au.id))
}
}
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
@ -552,7 +607,7 @@ class LibraryItemController {
}
// Get all library items to update
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds
})
if (updatePayloads.length !== libraryItems.length) {
@ -569,26 +624,46 @@ class LibraryItemController {
const mediaPayload = updatePayload.mediaPayload
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
if (libraryItem.isBook) {
if (Array.isArray(mediaPayload.metadata?.series)) {
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
const seriesUpdateData = await libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, libraryItem.libraryId)
if (seriesUpdateData?.seriesRemoved.length) {
seriesIdsRemoved.push(...seriesUpdateData.seriesRemoved.map((se) => se.id))
}
if (Array.isArray(mediaPayload.metadata?.authors)) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
if (seriesUpdateData?.seriesAdded.length) {
seriesUpdateData.seriesAdded.forEach((se) => {
Database.addSeriesToFilterData(libraryItem.libraryId, se.name, se.id)
})
}
if (seriesUpdateData?.hasUpdates) {
hasUpdates = true
}
}
if (libraryItem.media.update(mediaPayload)) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
const authorUpdateData = await libraryItem.media.updateAuthorsFromRequest(authorNames, libraryItem.libraryId)
if (authorUpdateData?.authorsRemoved.length) {
authorIdsRemoved.push(...authorUpdateData.authorsRemoved.map((au) => au.id))
hasUpdates = true
}
if (authorUpdateData?.authorsAdded.length) {
authorUpdateData.authorsAdded.forEach((au) => {
Database.addAuthorToFilterData(libraryItem.libraryId, au.name, au.id)
})
hasUpdates = true
}
}
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
if (hasUpdates) {
libraryItem.changed('updatedAt', true)
await libraryItem.save()
await libraryItem.saveMetadataFile()
Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`)
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
itemsUpdated++
}
}
@ -617,11 +692,11 @@ class LibraryItemController {
if (!libraryItemIds.length) {
return res.status(403).send('Invalid payload')
}
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds
})
res.json({
libraryItems: libraryItems.map((li) => li.toJSONExpanded())
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
})
}
@ -640,12 +715,11 @@ class LibraryItemController {
let itemsUpdated = 0
let itemsUnmatched = 0
const options = req.body.options || {}
if (!req.body.libraryItemIds?.length) {
return res.sendStatus(400)
}
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: req.body.libraryItemIds
})
if (!libraryItems?.length) {
@ -654,8 +728,20 @@ class LibraryItemController {
res.sendStatus(200)
const reqBodyOptions = req.body.options || {}
const options = {}
if (reqBodyOptions.provider && typeof reqBodyOptions.provider === 'string') {
options.provider = reqBodyOptions.provider
}
if (reqBodyOptions.overrideCover !== undefined) {
options.overrideCover = !!reqBodyOptions.overrideCover
}
if (reqBodyOptions.overrideDetails !== undefined) {
options.overrideDetails = !!reqBodyOptions.overrideDetails
}
for (const libraryItem of libraryItems) {
const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
if (matchResult.updated) {
itemsUpdated++
} else if (matchResult.warning) {
@ -714,7 +800,7 @@ class LibraryItemController {
/**
* POST: /api/items/:id/scan
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async scan(req, res) {
@ -738,7 +824,7 @@ class LibraryItemController {
/**
* GET: /api/items/:id/metadata-object
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
getMetadataObject(req, res) {
@ -747,7 +833,7 @@ class LibraryItemController {
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
}
@ -758,7 +844,7 @@ class LibraryItemController {
/**
* POST: /api/items/:id/chapters
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async updateMediaChapters(req, res) {
@ -767,26 +853,53 @@ class LibraryItemController {
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.hasAudioTracks) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
}
if (!req.body.chapters) {
if (!Array.isArray(req.body.chapters) || req.body.chapters.some((c) => !c.title || typeof c.title !== 'string' || c.start === undefined || typeof c.start !== 'number' || c.end === undefined || typeof c.end !== 'number')) {
Logger.error(`[LibraryItemController] Invalid payload`)
return res.sendStatus(400)
}
const chapters = req.body.chapters || []
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) {
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
let hasUpdates = false
if (chapters.length !== req.libraryItem.media.chapters.length) {
req.libraryItem.media.chapters = chapters.map((c, index) => {
return {
id: index,
title: c.title,
start: c.start,
end: c.end
}
})
hasUpdates = true
} else {
for (const [index, chapter] of chapters.entries()) {
const currentChapter = req.libraryItem.media.chapters[index]
if (currentChapter.title !== chapter.title || currentChapter.start !== chapter.start || currentChapter.end !== chapter.end) {
currentChapter.title = chapter.title
currentChapter.start = chapter.start
currentChapter.end = chapter.end
hasUpdates = true
}
}
}
if (hasUpdates) {
req.libraryItem.media.changed('chapters', true)
await req.libraryItem.media.save()
await req.libraryItem.saveMetadataFile()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
}
res.json({
success: true,
updated: wasUpdated
updated: hasUpdates
})
}
@ -794,7 +907,7 @@ class LibraryItemController {
* GET: /api/items/:id/ffprobe/:fileid
* FFProbe JSON result from audio file
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async getFFprobeData(req, res) {
@ -802,25 +915,21 @@ class LibraryItemController {
Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get ffprobe data`)
return res.sendStatus(403)
}
if (req.libraryFile.fileType !== 'audio') {
Logger.error(`[LibraryItemController] Invalid filetype "${req.libraryFile.fileType}" for fileid "${req.params.fileid}". Expected audio file`)
return res.sendStatus(400)
}
const audioFile = req.libraryItem.media.findFileWithInode(req.params.fileid)
const audioFile = req.libraryItem.getAudioFileWithIno(req.params.fileid)
if (!audioFile) {
Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`)
return res.sendStatus(404)
}
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile)
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile.metadata.path)
res.json(ffprobeData)
}
/**
* GET api/items/:id/file/:fileid
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res
*/
async getLibraryFile(req, res) {
@ -843,7 +952,7 @@ class LibraryItemController {
/**
* DELETE api/items/:id/file/:fileid
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res
*/
async deleteLibraryFile(req, res) {
@ -854,17 +963,49 @@ class LibraryItemController {
await fs.remove(libraryFile.metadata.path).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
})
req.libraryItem.removeLibraryFile(req.params.fileid)
if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) {
// If book has no more media files then mark it as missing
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
req.libraryItem.setMissing()
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((lf) => lf.ino !== req.params.fileid)
req.libraryItem.changed('libraryFiles', true)
if (req.libraryItem.isBook) {
if (req.libraryItem.media.audioFiles.some((af) => af.ino === req.params.fileid)) {
req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.filter((af) => af.ino !== req.params.fileid)
req.libraryItem.media.changed('audioFiles', true)
} else if (req.libraryItem.media.ebookFile?.ino === req.params.fileid) {
req.libraryItem.media.ebookFile = null
req.libraryItem.media.changed('ebookFile', true)
}
if (!req.libraryItem.media.hasMediaFiles) {
req.libraryItem.isMissing = true
}
} else if (req.libraryItem.media.podcastEpisodes.some((ep) => ep.audioFile.ino === req.params.fileid)) {
const episodeToRemove = req.libraryItem.media.podcastEpisodes.find((ep) => ep.audioFile.ino === req.params.fileid)
// Remove episode from all playlists
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])
// Remove episode media progress
const numProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
mediaItemId: episodeToRemove.id
}
})
if (numProgressRemoved > 0) {
Logger.info(`[LibraryItemController] Removed media progress for episode ${episodeToRemove.id}`)
}
// Remove episode
await episodeToRemove.destroy()
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid)
}
req.libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
if (req.libraryItem.media.changed()) {
await req.libraryItem.media.save()
}
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.sendStatus(200)
}
@ -872,7 +1013,7 @@ class LibraryItemController {
* GET api/items/:id/file/:fileid/download
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res
*/
async downloadLibraryFile(req, res) {
@ -884,7 +1025,7 @@ class LibraryItemController {
return res.sendStatus(403)
}
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" file at "${libraryFile.metadata.path}"`)
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" file at "${libraryFile.metadata.path}"`)
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path)
@ -920,13 +1061,13 @@ class LibraryItemController {
* fileid is only required when reading a supplementary ebook
* when no fileid is passed in the primary ebook will be returned
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async getEBookFile(req, res) {
let ebookFile = null
if (req.params.fileid) {
ebookFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
ebookFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
if (!ebookFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id')
@ -936,12 +1077,12 @@ class LibraryItemController {
}
if (!ebookFile) {
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`)
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.title}"`)
return res.sendStatus(404)
}
const ebookFilePath = ebookFile.metadata.path
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" ebook at "${ebookFilePath}"`)
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" ebook at "${ebookFilePath}"`)
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + ebookFilePath)
@ -964,28 +1105,55 @@ class LibraryItemController {
* if an ebook file is the primary ebook, then it will be changed to supplementary
* if an ebook file is supplementary, then it will be changed to primary
*
* @param {RequestWithUser} req
* @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res
*/
async updateEbookFileStatus(req, res) {
const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
if (!ebookLibraryFile?.isEBookFile) {
if (!req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid media type for ebook file status update`)
return res.sendStatus(400)
}
if (!req.libraryFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id')
}
const ebookLibraryFile = req.libraryFile
let primaryEbookFile = null
const ebookLibraryFileInos = req.libraryItem
.getLibraryFiles()
.filter((lf) => lf.isEBookFile)
.map((lf) => lf.ino)
if (ebookLibraryFile.isSupplementary) {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`)
req.libraryItem.setPrimaryEbook(ebookLibraryFile)
primaryEbookFile = ebookLibraryFile.toJSON()
delete primaryEbookFile.isSupplementary
delete primaryEbookFile.fileType
primaryEbookFile.ebookFormat = ebookLibraryFile.metadata.format
} else {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`)
ebookLibraryFile.isSupplementary = true
req.libraryItem.setPrimaryEbook(null)
}
req.libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
req.libraryItem.media.ebookFile = primaryEbookFile
req.libraryItem.media.changed('ebookFile', true)
await req.libraryItem.media.save()
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.map((lf) => {
if (ebookLibraryFileInos.includes(lf.ino)) {
lf.isSupplementary = lf.ino !== primaryEbookFile?.ino
}
return lf
})
req.libraryItem.changed('libraryFiles', true)
req.libraryItem.isMissing = !req.libraryItem.media.hasMediaFiles
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.sendStatus(200)
}
@ -996,7 +1164,7 @@ class LibraryItemController {
* @param {NextFunction} next
*/
async middleware(req, res, next) {
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item
@ -1006,7 +1174,7 @@ class LibraryItemController {
// For library file routes, get the library file
if (req.params.fileid) {
req.libraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
req.libraryFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
if (!req.libraryFile) {
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
return res.sendStatus(404)

View file

@ -66,7 +66,7 @@ class MeController {
const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId)
if (!libraryItem || (libraryItem.mediaType === 'podcast' && !episode)) {
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[MeController] Media item not found for library item id "${req.params.libraryItemId}"`)
return res.sendStatus(404)
}
@ -296,7 +296,7 @@ class MeController {
const mediaProgressesInProgress = req.user.mediaProgresses.filter((mp) => !mp.isFinished && (mp.currentTime > 0 || mp.ebookProgress > 0))
const libraryItemsIds = [...new Set(mediaProgressesInProgress.map((mp) => mp.extraData?.libraryItemId).filter((id) => id))]
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: libraryItemsIds })
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemsIds })
let itemsInProgress = []
@ -304,19 +304,19 @@ class MeController {
const oldMediaProgress = mediaProgress.getOldMediaProgress()
const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId)
if (libraryItem) {
if (oldMediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find((ep) => ep.id === oldMediaProgress.episodeId)
if (oldMediaProgress.episodeId && libraryItem.isPodcast) {
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === oldMediaProgress.episodeId)
if (episode) {
const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(),
recentEpisode: episode.toJSON(),
...libraryItem.toOldJSONMinified(),
recentEpisode: episode.toOldJSON(libraryItem.id),
progressLastUpdate: oldMediaProgress.lastUpdate
}
itemsInProgress.push(libraryItemWithEpisode)
}
} else if (!oldMediaProgress.episodeId) {
itemsInProgress.push({
...libraryItem.toJSONMinified(),
...libraryItem.toOldJSONMinified(),
progressLastUpdate: oldMediaProgress.lastUpdate
})
}

View file

@ -126,6 +126,10 @@ class MiscController {
if (!isObject(settingsUpdate)) {
return res.status(400).send('Invalid settings update object')
}
if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') {
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
}
const madeUpdates = Database.serverSettings.update(settingsUpdate)
if (madeUpdates) {
@ -137,7 +141,6 @@ class MiscController {
}
}
return res.json({
success: true,
serverSettings: Database.serverSettings.toJSONForBrowser()
})
}
@ -339,8 +342,8 @@ class MiscController {
tags: libraryItem.media.tags
})
await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++
}
}
@ -382,8 +385,8 @@ class MiscController {
tags: libraryItem.media.tags
})
await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++
}
@ -477,8 +480,8 @@ class MiscController {
genres: libraryItem.media.genres
})
await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++
}
}
@ -520,8 +523,8 @@ class MiscController {
genres: libraryItem.media.genres
})
await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++
}

View file

@ -3,13 +3,16 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const Playlist = require('../objects/Playlist')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/Playlist')} playlist
*
* @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest
*/
class PlaylistController {
@ -23,48 +26,103 @@ class PlaylistController {
* @param {Response} res
*/
async create(req, res) {
const oldPlaylist = new Playlist()
req.body.userId = req.user.id
const success = oldPlaylist.setData(req.body)
if (!success) {
return res.status(400).send('Invalid playlist request data')
const reqBody = req.body || {}
// Validation
if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid playlist data')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description')
}
const items = reqBody.items || []
const isPodcast = items.some((i) => i.episodeId)
const libraryItemIds = new Set()
for (const item of items) {
if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
return res.status(400).send('Invalid playlist item')
}
if (isPodcast && (!item.episodeId || typeof item.episodeId !== 'string')) {
return res.status(400).send('Invalid playlist item episodeId')
} else if (!isPodcast && item.episodeId) {
return res.status(400).send('Invalid playlist item episodeId')
}
libraryItemIds.add(item.libraryItemId)
}
// Create Playlist record
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
// Lookup all library items in playlist
const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId).filter((i) => i)
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
// Load library items
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
where: {
id: libraryItemIds
id: Array.from(libraryItemIds),
libraryId: reqBody.libraryId,
mediaType: isPodcast ? 'podcast' : 'book'
}
})
if (libraryItems.length !== libraryItemIds.size) {
return res.status(400).send('Invalid playlist data. Invalid items')
}
// Create playlistMediaItem records
const mediaItemsToAdd = []
let order = 1
for (const mediaItemObj of oldPlaylist.items) {
const libraryItem = libraryItemsInPlaylist.find((li) => li.id === mediaItemObj.libraryItemId)
if (!libraryItem) continue
mediaItemsToAdd.push({
mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
playlistId: oldPlaylist.id,
order: order++
// Validate podcast episodes
if (isPodcast) {
const podcastEpisodeIds = items.map((i) => i.episodeId)
const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
attributes: ['id'],
where: {
id: podcastEpisodeIds
}
})
}
if (mediaItemsToAdd.length) {
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
if (podcastEpisodes.length !== podcastEpisodeIds.length) {
return res.status(400).send('Invalid playlist data. Invalid podcast episodes')
}
}
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
const transaction = await Database.sequelize.transaction()
try {
// Create playlist
const newPlaylist = await Database.playlistModel.create(
{
libraryId: reqBody.libraryId,
userId: req.user.id,
name: reqBody.name,
description: reqBody.description || null
},
{ transaction }
)
// Create playlistMediaItems
const playlistItemPayloads = []
for (const [index, item] of items.entries()) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
playlistItemPayloads.push({
playlistId: newPlaylist.id,
mediaItemId: item.episodeId || libraryItem.mediaId,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: index + 1
})
}
await Database.playlistMediaItemModel.bulkCreate(playlistItemPayloads, { transaction })
await transaction.commit()
newPlaylist.playlistMediaItems = await newPlaylist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = newPlaylist.toOldJSONExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
} catch (error) {
await transaction.rollback()
Logger.error('[PlaylistController] create:', error)
res.status(500).send('Failed to create playlist')
}
}
/**
* @deprecated - Use /api/libraries/:libraryId/playlists
* This is not used by Abs web client or mobile apps
* TODO: Remove this endpoint or make it the primary
*
* GET: /api/playlists
* Get all playlists for user
*
@ -72,68 +130,89 @@ class PlaylistController {
* @param {Response} res
*/
async findAllForUser(req, res) {
const playlistsForUser = await Database.playlistModel.findAll({
where: {
userId: req.user.id
}
})
const playlists = []
for (const playlist of playlistsForUser) {
const jsonExpanded = await playlist.getOldJsonExpanded()
playlists.push(jsonExpanded)
}
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
res.json({
playlists
playlists: playlistsForUser
})
}
/**
* GET: /api/playlists/:id
*
* @param {RequestWithUser} req
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async findOne(req, res) {
const jsonExpanded = await req.playlist.getOldJsonExpanded()
res.json(jsonExpanded)
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
res.json(req.playlist.toOldJSONExpanded())
}
/**
* PATCH: /api/playlists/:id
* Update playlist
*
* @param {RequestWithUser} req
* Used for updating name and description or reordering items
*
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async update(req, res) {
const updatedPlaylist = req.playlist.set(req.body)
let wasUpdated = false
const changed = updatedPlaylist.changed()
if (changed?.length) {
await req.playlist.save()
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
wasUpdated = true
// Validation
const reqBody = req.body || {}
if (reqBody.libraryId || reqBody.userId) {
// Could allow support for this if needed with additional validation
return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')
}
if (reqBody.name && typeof reqBody.name !== 'string') {
return res.status(400).send('Invalid playlist name')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description')
}
if (reqBody.items && (!Array.isArray(reqBody.items) || reqBody.items.some((i) => !i.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string')))) {
return res.status(400).send('Invalid playlist items')
}
// If array of items is passed in then update order of playlist media items
const libraryItemIds = req.body.items?.map((i) => i.libraryItemId).filter((i) => i) || []
if (libraryItemIds.length) {
const playlistUpdatePayload = {}
if (reqBody.name) playlistUpdatePayload.name = reqBody.name
if (reqBody.description) playlistUpdatePayload.description = reqBody.description
// Update name and description
let wasUpdated = false
if (Object.keys(playlistUpdatePayload).length) {
req.playlist.set(playlistUpdatePayload)
const changed = req.playlist.changed()
if (changed?.length) {
await req.playlist.save()
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
wasUpdated = true
}
}
// If array of items is set then update order of playlist media items
if (reqBody.items?.length) {
const libraryItemIds = Array.from(new Set(reqBody.items.map((i) => i.libraryItemId)))
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType'],
where: {
id: libraryItemIds
}
})
const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({
if (libraryItems.length !== libraryItemIds.length) {
return res.status(400).send('Invalid playlist items. Items not found')
}
/** @type {import('../models/PlaylistMediaItem')[]} */
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
if (existingPlaylistMediaItems.length !== reqBody.items.length) {
return res.status(400).send('Invalid playlist items. Length mismatch')
}
// Set an array of mediaItemId
const newMediaItemIdOrder = []
for (const item of req.body.items) {
for (const item of reqBody.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) {
continue
}
const mediaItemId = item.episodeId || libraryItem.mediaId
newMediaItemIdOrder.push(mediaItemId)
}
@ -146,21 +225,21 @@ class PlaylistController {
})
// Update order on playlistMediaItem records
let order = 1
for (const playlistMediaItem of existingPlaylistMediaItems) {
if (playlistMediaItem.order !== order) {
for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {
if (playlistMediaItem.order !== index + 1) {
await playlistMediaItem.update({
order
order: index + 1
})
wasUpdated = true
}
order++
}
}
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded()
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
if (wasUpdated) {
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
@ -169,11 +248,13 @@ class PlaylistController {
* DELETE: /api/playlists/:id
* Remove playlist
*
* @param {RequestWithUser} req
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async delete(req, res) {
const jsonExpanded = await req.playlist.getOldJsonExpanded()
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200)
@ -183,43 +264,64 @@ class PlaylistController {
* POST: /api/playlists/:id/item
* Add item to playlist
*
* @param {RequestWithUser} req
* This is not used by Abs web client or mobile apps. Only the batch endpoints are used.
*
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async addItem(req, res) {
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
const itemToAdd = req.body
const itemToAdd = req.body || {}
if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId')
}
const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
const libraryItem = await Database.libraryItemModel.getExpandedById(itemToAdd.libraryItemId)
if (!libraryItem) {
return res.status(400).send('Library item not found')
}
if (libraryItem.libraryId !== oldPlaylist.libraryId) {
if (libraryItem.libraryId !== req.playlist.libraryId) {
return res.status(400).send('Library item in different library')
}
if (oldPlaylist.containsItem(itemToAdd)) {
return res.status(400).send('Item already in playlist')
}
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
return res.status(400).send('Invalid item to add for this library type')
}
if (itemToAdd.episodeId && !libraryItem.media.checkHasEpisode(itemToAdd.episodeId)) {
if (itemToAdd.episodeId && !libraryItem.media.podcastEpisodes.some((pe) => pe.id === itemToAdd.episodeId)) {
return res.status(400).send('Episode not found in library item')
}
const playlistMediaItem = {
playlistId: oldPlaylist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
order: oldPlaylist.items.length + 1
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {
return res.status(400).send('Item already in playlist')
}
const jsonExpanded = req.playlist.toOldJSONExpanded()
const playlistMediaItem = {
playlistId: req.playlist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
order: req.playlist.playlistMediaItems.length + 1
}
await Database.playlistMediaItemModel.create(playlistMediaItem)
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
if (itemToAdd.episodeId) {
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === itemToAdd.episodeId)
jsonExpanded.items.push({
episodeId: itemToAdd.episodeId,
episode: episode.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONMinified()
})
} else {
jsonExpanded.items.push({
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONExpanded()
})
}
await Database.createPlaylistMediaItem(playlistMediaItem)
const jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded)
}
@ -228,43 +330,36 @@ class PlaylistController {
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
* Remove item from playlist
*
* @param {RequestWithUser} req
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async removeItem(req, res) {
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
if (!oldLibraryItem) {
return res.status(404).send('Library item not found')
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
let playlistMediaItem = null
if (req.params.episodeId) {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === req.params.episodeId)
} else {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === req.params.libraryItemId)
}
// Get playlist media items
const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
// Check if media item to delete is in playlist
const mediaItemToRemove = playlistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
if (!mediaItemToRemove) {
if (!playlistMediaItem) {
return res.status(404).send('Media item not found in playlist')
}
// Remove record
await mediaItemToRemove.destroy()
await playlistMediaItem.destroy()
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
// Update playlist media items order
let order = 1
for (const mediaItem of playlistMediaItems) {
if (mediaItem.mediaItemId === mediaItemId) continue
if (mediaItem.order !== order) {
for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
if (mediaItem.order !== index + 1) {
await mediaItem.update({
order
order: index + 1
})
}
order++
}
const jsonExpanded = await req.playlist.getOldJsonExpanded()
const jsonExpanded = req.playlist.toOldJSONExpanded()
// Playlist is removed when there are no items
if (!jsonExpanded.items.length) {
@ -282,64 +377,68 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/add
* Batch add playlist items
*
* @param {RequestWithUser} req
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async addBatch(req, res) {
if (!req.body.items?.length) {
return res.status(400).send('Invalid request body')
}
const itemsToAdd = req.body.items
const libraryItemIds = itemsToAdd.map((i) => i.libraryItemId).filter((i) => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
return res.status(400).send('Invalid request body items')
}
// Find all library items
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
})
const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
// Get all existing playlist media items
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) })
if (libraryItems.length !== libraryItemIds.size) {
return res.status(400).send('Invalid request body items')
}
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const mediaItemsToAdd = []
const jsonExpanded = req.playlist.toOldJSONExpanded()
// Setup array of playlistMediaItem records to add
let order = existingPlaylistMediaItems.length + 1
for (const item of itemsToAdd) {
let order = req.playlist.playlistMediaItems.length + 1
for (const item of req.body.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) {
return res.status(404).send('Item not found with id ' + item.libraryItemId)
const mediaItemId = item.episodeId || libraryItem.media.id
if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
// Already exists in playlist
continue
} else {
const mediaItemId = item.episodeId || libraryItem.mediaId
if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
// Already exists in playlist
continue
mediaItemsToAdd.push({
playlistId: req.playlist.id,
mediaItemId,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
})
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
if (item.episodeId) {
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === item.episodeId)
jsonExpanded.items.push({
episodeId: item.episodeId,
episode: episode.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONMinified()
})
} else {
mediaItemsToAdd.push({
playlistId: req.playlist.id,
mediaItemId,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
jsonExpanded.items.push({
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONExpanded()
})
}
}
}
let jsonExpanded = null
if (mediaItemsToAdd.length) {
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
jsonExpanded = await req.playlist.getOldJsonExpanded()
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
} else {
jsonExpanded = await req.playlist.getOldJsonExpanded()
}
res.json(jsonExpanded)
}
@ -347,50 +446,40 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/remove
* Batch remove playlist items
*
* @param {RequestWithUser} req
* @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async removeBatch(req, res) {
if (!req.body.items?.length) {
return res.status(400).send('Invalid request body')
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
return res.status(400).send('Invalid request body items')
}
const itemsToRemove = req.body.items
const libraryItemIds = itemsToRemove.map((i) => i.libraryItemId).filter((i) => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
}
// Find all library items
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
})
// Get all existing playlist media items for playlist
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
let numMediaItems = existingPlaylistMediaItems.length
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
// Remove playlist media items
let hasUpdated = false
for (const item of itemsToRemove) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) continue
const mediaItemId = item.episodeId || libraryItem.mediaId
const existingMediaItem = existingPlaylistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
if (!existingMediaItem) continue
await existingMediaItem.destroy()
for (const item of req.body.items) {
let playlistMediaItem = null
if (item.episodeId) {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)
} else {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)
}
if (!playlistMediaItem) {
Logger.warn(`[PlaylistController] Playlist item not found in playlist ${req.playlist.id}`, item)
continue
}
await playlistMediaItem.destroy()
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
hasUpdated = true
numMediaItems--
}
const jsonExpanded = await req.playlist.getOldJsonExpanded()
const jsonExpanded = req.playlist.toOldJSONExpanded()
if (hasUpdated) {
// Playlist is removed when there are no items
if (!numMediaItems) {
if (!req.playlist.playlistMediaItems.length) {
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
@ -425,33 +514,41 @@ class PlaylistController {
return res.status(400).send('Collection has no books')
}
const oldPlaylist = new Playlist()
oldPlaylist.setData({
userId: req.user.id,
libraryId: collection.libraryId,
name: collection.name,
description: collection.description || null
})
const transaction = await Database.sequelize.transaction()
try {
const playlist = await Database.playlistModel.create(
{
userId: req.user.id,
libraryId: collection.libraryId,
name: collection.name,
description: collection.description || null
},
{ transaction }
)
// Create Playlist record
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
const mediaItemsToAdd = []
for (const [index, libraryItem] of collectionExpanded.books.entries()) {
mediaItemsToAdd.push({
playlistId: playlist.id,
mediaItemId: libraryItem.media.id,
mediaItemType: 'book',
order: index + 1
})
}
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd, { transaction })
// Create PlaylistMediaItem records
const mediaItemsToAdd = []
let order = 1
for (const libraryItem of collectionExpanded.books) {
mediaItemsToAdd.push({
playlistId: newPlaylist.id,
mediaItemId: libraryItem.media.id,
mediaItemType: 'book',
order: order++
})
await transaction.commit()
playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = playlist.toOldJSONExpanded()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
} catch (error) {
await transaction.rollback()
Logger.error('[PlaylistController] createFromCollection:', error)
res.status(500).send('Failed to create playlist')
}
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
}
/**

View file

@ -1,3 +1,4 @@
const Path = require('path')
const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
@ -12,13 +13,16 @@ const { validateUrl } = require('../utils/index')
const Scanner = require('../scanner/Scanner')
const CoverManager = require('../managers/CoverManager')
const LibraryItem = require('../objects/LibraryItem')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
*/
class PodcastController {
@ -37,6 +41,9 @@ class PodcastController {
return res.sendStatus(403)
}
const payload = req.body
if (!payload.media || !payload.media.metadata) {
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
}
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
if (!library) {
@ -78,48 +85,87 @@ class PodcastController {
let relPath = payload.path.replace(folder.fullPath, '')
if (relPath.startsWith('/')) relPath = relPath.slice(1)
const libraryItemPayload = {
path: podcastPath,
relPath,
folderId: payload.folderId,
libraryId: payload.libraryId,
ino: libraryItemFolderStats.ino,
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
media: payload.media
let newLibraryItem = null
const transaction = await Database.sequelize.transaction()
try {
const podcast = await Database.podcastModel.createFromRequest(payload.media, transaction)
newLibraryItem = await Database.libraryItemModel.create(
{
ino: libraryItemFolderStats.ino,
path: podcastPath,
relPath,
mediaId: podcast.id,
mediaType: 'podcast',
isFile: false,
isMissing: false,
isInvalid: false,
mtime: libraryItemFolderStats.mtimeMs || 0,
ctime: libraryItemFolderStats.ctimeMs || 0,
birthtime: libraryItemFolderStats.birthtimeMs || 0,
size: 0,
libraryFiles: [],
extraData: {},
libraryId: library.id,
libraryFolderId: folder.id
},
{ transaction }
)
await transaction.commit()
} catch (error) {
Logger.error(`[PodcastController] Failed to create podcast: ${error}`)
await transaction.rollback()
return res.status(500).send('Failed to create podcast')
}
const libraryItem = new LibraryItem()
libraryItem.setData('podcast', libraryItemPayload)
newLibraryItem.media = await newLibraryItem.getMediaExpanded()
// Download and save cover image
if (payload.media.metadata.imageUrl) {
// TODO: Scan cover image to library files
if (typeof payload.media.metadata.imageUrl === 'string' && payload.media.metadata.imageUrl.startsWith('http')) {
// Podcast cover will always go into library item folder
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
if (coverResponse) {
if (coverResponse.error) {
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
} else if (coverResponse.cover) {
libraryItem.media.coverPath = coverResponse.cover
const coverResponse = await CoverManager.downloadCoverFromUrlNew(payload.media.metadata.imageUrl, newLibraryItem.id, newLibraryItem.path, true)
if (coverResponse.error) {
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
} else if (coverResponse.cover) {
const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
if (!coverImageFileStats) {
Logger.error(`[PodcastController] Failed to get cover image stats for "${coverResponse.cover}"`)
} else {
// Add libraryFile to libraryItem and coverPath to podcast
const newLibraryFile = {
ino: coverImageFileStats.ino,
fileType: 'image',
addedAt: Date.now(),
updatedAt: Date.now(),
metadata: {
filename: Path.basename(coverResponse.cover),
ext: Path.extname(coverResponse.cover).slice(1),
path: coverResponse.cover,
relPath: Path.basename(coverResponse.cover),
size: coverImageFileStats.size,
mtimeMs: coverImageFileStats.mtimeMs || 0,
ctimeMs: coverImageFileStats.ctimeMs || 0,
birthtimeMs: coverImageFileStats.birthtimeMs || 0
}
}
newLibraryItem.libraryFiles.push(newLibraryFile)
newLibraryItem.changed('libraryFiles', true)
await newLibraryItem.save()
newLibraryItem.media.coverPath = coverResponse.cover
await newLibraryItem.media.save()
}
}
}
await Database.createLibraryItem(libraryItem)
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
res.json(libraryItem.toJSONExpanded())
if (payload.episodesToDownload?.length) {
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
}
res.json(newLibraryItem.toOldJSONExpanded())
// Turn on podcast auto download cron if not already on
if (libraryItem.media.autoDownloadEpisodes) {
this.cronManager.checkUpdatePodcastCron(libraryItem)
if (newLibraryItem.media.autoDownloadEpisodes) {
this.cronManager.checkUpdatePodcastCron(newLibraryItem)
}
}
@ -213,7 +259,7 @@ class PodcastController {
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async checkNewEpisodes(req, res) {
@ -222,15 +268,14 @@ class PodcastController {
return res.sendStatus(403)
}
var libraryItem = req.libraryItem
if (!libraryItem.media.metadata.feedUrl) {
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
return res.status(500).send('Podcast has no rss feed url')
if (!req.libraryItem.media.feedURL) {
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`)
return res.status(400).send('Podcast has no rss feed url')
}
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
const newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(req.libraryItem, maxEpisodesToDownload)
res.json({
episodes: newEpisodes || []
})
@ -258,23 +303,28 @@ class PodcastController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
getEpisodeDownloads(req, res) {
var libraryItem = req.libraryItem
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
res.json({
downloads: downloadsInQueue.map((d) => d.toJSONForClient())
})
}
/**
* GET: /api/podcasts/:id/search-episode
* Search for an episode in a podcast
*
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async findEpisode(req, res) {
const rssFeedUrl = req.libraryItem.media.metadata.feedUrl
const rssFeedUrl = req.libraryItem.media.feedURL
if (!rssFeedUrl) {
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`)
return res.status(500).send('Podcast does not have an RSS feed URL')
return res.status(400).send('Podcast does not have an RSS feed URL')
}
const searchTitle = req.query.title
@ -292,7 +342,7 @@ class PodcastController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async downloadEpisodes(req, res) {
@ -300,13 +350,13 @@ class PodcastController {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
return res.sendStatus(403)
}
const libraryItem = req.libraryItem
const episodes = req.body
if (!episodes?.length) {
if (!Array.isArray(episodes) || !episodes.length) {
return res.sendStatus(400)
}
this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes)
this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes)
res.sendStatus(200)
}
@ -315,7 +365,7 @@ class PodcastController {
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async quickMatchEpisodes(req, res) {
@ -327,8 +377,7 @@ class PodcastController {
const overrideDetails = req.query.override === '1'
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
if (episodesUpdated) {
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
}
res.json({
@ -339,58 +388,76 @@ class PodcastController {
/**
* PATCH: /api/podcasts/:id/episode/:episodeId
*
* @param {RequestWithUser} req
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async updateEpisode(req, res) {
const libraryItem = req.libraryItem
var episodeId = req.params.episodeId
if (!libraryItem.media.checkHasEpisode(episodeId)) {
/** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId)
if (!episode) {
return res.status(404).send('Episode not found')
}
if (libraryItem.media.updateEpisode(episodeId, req.body)) {
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
const updatePayload = {}
const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType']
for (const key in req.body) {
if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') {
updatePayload[key] = req.body[key]
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
updatePayload[key] = req.body[key]
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
updatePayload[key] = req.body[key]
}
}
res.json(libraryItem.toJSONExpanded())
if (Object.keys(updatePayload).length) {
episode.set(updatePayload)
if (episode.changed()) {
Logger.info(`[PodcastController] Updated episode "${episode.title}" keys`, episode.changed())
await episode.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
} else {
Logger.info(`[PodcastController] No changes to episode "${episode.title}"`)
}
}
res.json(req.libraryItem.toOldJSONExpanded())
}
/**
* GET: /api/podcasts/:id/episode/:episodeId
*
* @param {RequestWithUser} req
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async getEpisode(req, res) {
const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
/** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
return res.sendStatus(404)
}
res.json(episode)
res.json(episode.toOldJSON(req.libraryItem.id))
}
/**
* DELETE: /api/podcasts/:id/episode/:episodeId
*
* @param {RequestWithUser} req
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async removeEpisode(req, res) {
const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const hardDelete = req.query.hard === '1'
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
/** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
return res.sendStatus(404)
}
@ -407,36 +474,8 @@ class PodcastController {
})
}
// Remove episode from Podcast and library file
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
if (episodeRemoved?.audioFile) {
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
}
// Update/remove playlists that had this podcast episode
const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
where: {
mediaItemId: episodeId
},
include: {
model: Database.playlistModel,
include: Database.playlistMediaItemModel
}
})
for (const pmi of playlistMediaItems) {
const numItems = pmi.playlist.playlistMediaItems.length - 1
if (!numItems) {
Logger.info(`[PodcastController] Playlist "${pmi.playlist.name}" has no more items - removing it`)
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
await pmi.playlist.destroy()
} else {
await pmi.destroy()
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
}
}
// Remove episode from playlists
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeId])
// Remove media progress for this episode
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
@ -448,9 +487,16 @@ class PodcastController {
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
}
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON())
// Remove episode
await episode.destroy()
// Remove library file
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((file) => file.ino !== episode.audioFile.ino)
req.libraryItem.changed('libraryFiles', true)
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.json(req.libraryItem.toOldJSON())
}
/**
@ -460,15 +506,15 @@ class PodcastController {
* @param {NextFunction} next
*/
async middleware(req, res, next) {
const item = await Database.libraryItemModel.getOldById(req.params.id)
if (!item?.media) return res.sendStatus(404)
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!libraryItem?.media) return res.sendStatus(404)
if (!item.isPodcast) {
if (!libraryItem.isPodcast) {
return res.sendStatus(500)
}
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403)
}
@ -480,7 +526,7 @@ class PodcastController {
return res.sendStatus(403)
}
req.libraryItem = item
req.libraryItem = libraryItem
next()
}
}

View file

@ -1,7 +1,8 @@
const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const RssFeedManager = require('../managers/RssFeedManager')
/**
* @typedef RequestUserObject
@ -22,10 +23,10 @@ class RSSFeedController {
* @param {Response} res
*/
async getAll(req, res) {
const feeds = await this.rssFeedManager.getFeeds()
const feeds = await RssFeedManager.getFeeds()
res.json({
feeds: feeds.map((f) => f.toJSON()),
minified: feeds.map((f) => f.toJSONMinified())
feeds: feeds.map((f) => f.toOldJSON()),
minified: feeds.map((f) => f.toOldJSONMinified())
})
}
@ -38,38 +39,43 @@ class RSSFeedController {
* @param {Response} res
*/
async openRSSFeedForItem(req, res) {
const options = req.body || {}
const reqBody = req.body || {}
const item = await Database.libraryItemModel.getOldById(req.params.itemId)
if (!item) return res.sendStatus(404)
const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)
if (!itemExpanded) return res.sendStatus(404)
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${item.media.metadata.title}" that they don\'t have access to`)
if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${itemExpanded.media.title}" that they don\'t have access to`)
return res.sendStatus(403)
}
// Check request body options exist
if (!options.serverAddress || !options.slug) {
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body')
}
// Check item has audio tracks
if (!item.media.numTracks) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`)
if (!itemExpanded.hasAudioTracks()) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
return res.status(400).send('Item has no audio tracks')
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body)
const feed = await RssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({
feed: feed.toJSONMinified()
feed: feed.toOldJSONMinified()
})
}
@ -82,35 +88,37 @@ class RSSFeedController {
* @param {Response} res
*/
async openRSSFeedForCollection(req, res) {
const options = req.body || {}
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) return res.sendStatus(404)
const reqBody = req.body || {}
// Check request body options exist
if (!options.serverAddress || !options.slug) {
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body')
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const collectionExpanded = await collection.getOldJsonExpanded()
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length)
const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)
if (!collection) return res.sendStatus(404)
// Check collection has audio tracks
if (!collectionItemsWithTracks.length) {
if (!collection.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
return res.status(400).send('Collection has no audio tracks')
}
const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collectionExpanded, req.body)
const feed = await RssFeedManager.openFeedForCollection(req.user.id, collection, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for collection "${collection.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({
feed: feed.toJSONMinified()
feed: feed.toOldJSONMinified()
})
}
@ -123,37 +131,37 @@ class RSSFeedController {
* @param {Response} res
*/
async openRSSFeedForSeries(req, res) {
const options = req.body || {}
const series = await Database.seriesModel.findByPk(req.params.seriesId)
if (!series) return res.sendStatus(404)
const reqBody = req.body || {}
// Check request body options exist
if (!options.serverAddress || !options.slug) {
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body')
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const seriesJson = series.toOldJSON()
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
const series = await Database.seriesModel.getExpandedById(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Check series has audio tracks
if (!seriesJson.books.length) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`)
if (!series.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${series.name}" because it has no audio tracks`)
return res.status(400).send('Series has no audio tracks')
}
const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body)
const feed = await RssFeedManager.openFeedForSeries(req.user.id, series, req.body)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for series "${series.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({
feed: feed.toJSONMinified()
feed: feed.toOldJSONMinified()
})
}
@ -165,8 +173,16 @@ class RSSFeedController {
* @param {RequestWithUser} req
* @param {Response} res
*/
closeRSSFeed(req, res) {
this.rssFeedManager.closeRssFeed(req, res)
async closeRSSFeed(req, res) {
const feed = await Database.feedModel.findByPk(req.params.id)
if (!feed) {
Logger.error(`[RSSFeedController] Cannot close RSS feed because feed "${req.params.id}" does not exist`)
return res.sendStatus(404)
}
await RssFeedManager.handleCloseFeed(feed)
res.sendStatus(200)
}
/**

View file

@ -24,7 +24,7 @@ class SearchController {
*/
async findBooks(req, res) {
const id = req.query.id
const libraryItem = await Database.libraryItemModel.getOldById(id)
const libraryItem = await Database.libraryItemModel.getExpandedById(id)
const provider = req.query.provider || 'google'
const title = req.query.title || ''
const author = req.query.author || ''

View file

@ -2,6 +2,9 @@ const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
/**
@ -51,8 +54,8 @@ class SeriesController {
}
if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
}
res.json(seriesJson)

View file

@ -149,7 +149,7 @@ class SessionController {
* @param {Response} res
*/
async getOpenSession(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId)
const libraryItem = await Database.libraryItemModel.getExpandedById(req.playbackSession.libraryItemId)
const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
res.json(sessionForClient)
}

View file

@ -7,6 +7,7 @@ const Database = require('../Database')
const { PlayMethod } = require('../utils/constants')
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
const zipHelpers = require('../utils/zipHelpers')
const PlaybackSession = require('../objects/PlaybackSession')
const ShareManager = require('../managers/ShareManager')
@ -69,14 +70,13 @@ class ShareController {
}
try {
const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
if (!oldLibraryItem) {
const libraryItem = await Database.mediaItemShareModel.getMediaItemsLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
if (!libraryItem) {
return res.status(404).send('Media item not found')
}
let startOffset = 0
const publicTracks = oldLibraryItem.media.includedAudioFiles.map((audioFile) => {
const publicTracks = libraryItem.media.includedAudioFiles.map((audioFile) => {
const audioTrack = {
index: audioFile.index,
startOffset,
@ -85,7 +85,7 @@ class ShareController {
contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`,
mimeType: audioFile.mimeType,
codec: audioFile.codec || null,
metadata: audioFile.metadata.clone()
metadata: structuredClone(audioFile.metadata)
}
startOffset += audioTrack.duration
return audioTrack
@ -104,12 +104,12 @@ class ShareController {
const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo)
const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(oldLibraryItem, null, 'web-share', deviceInfo, startTime)
newPlaybackSession.setData(libraryItem, null, 'web-share', deviceInfo, startTime)
newPlaybackSession.audioTracks = publicTracks
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
newPlaybackSession.shareSessionId = shareSessionId
newPlaybackSession.mediaItemShareId = mediaItemShare.id
newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio
newPlaybackSession.coverAspectRatio = libraryItem.library.settings.coverAspectRatio
mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient()
ShareManager.addOpenSharePlaybackSession(newPlaybackSession)
@ -210,6 +210,65 @@ class ShareController {
res.sendFile(audioTrackPath)
}
/**
* Public route - requires share_session_id cookie
*
* GET: /api/share/:slug/download
* Downloads media item share
*
* @param {Request} req
* @param {Response} res
*/
async downloadMediaItemShare(req, res) {
if (!req.cookies.share_session_id) {
return res.status(404).send('Share session not set')
}
const { slug } = req.params
const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) {
return res.status(404)
}
if (!mediaItemShare.isDownloadable) {
return res.status(403).send('Download is not allowed for this item')
}
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
return res.status(404).send('Share session not found')
}
const libraryItem = await Database.libraryItemModel.findByPk(playbackSession.libraryItemId, {
attributes: ['id', 'path', 'relPath', 'isFile']
})
if (!libraryItem) {
return res.status(404).send('Library item not found')
}
const itemPath = libraryItem.path
const itemTitle = playbackSession.displayTitle
Logger.info(`[ShareController] Requested download for book "${itemTitle}" at "${itemPath}"`)
try {
if (libraryItem.isFile) {
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(itemPath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
await new Promise((resolve, reject) => res.download(itemPath, libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
} else {
const filename = `${itemTitle}.zip`
await zipHelpers.zipDirectoryPipe(itemPath, filename, res)
}
Logger.info(`[ShareController] Downloaded item "${itemTitle}" at "${itemPath}"`)
} catch (error) {
Logger.error(`[ShareController] Download failed for item "${itemTitle}" at "${itemPath}"`, error)
res.status(500).send('Failed to download the item')
}
}
/**
* Public route - requires share_session_id cookie
*
@ -259,7 +318,7 @@ class ShareController {
return res.sendStatus(403)
}
const { slug, expiresAt, mediaItemType, mediaItemId } = req.body
const { slug, expiresAt, mediaItemType, mediaItemId, isDownloadable } = req.body
if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') {
return res.status(400).send('Missing or invalid required fields')
@ -298,7 +357,8 @@ class ShareController {
expiresAt: expiresAt || null,
mediaItemId,
mediaItemType,
userId: req.user.id
userId: req.user.id,
isDownloadable
})
ShareManager.openMediaItemShare(mediaItemShare)

View file

@ -7,6 +7,11 @@ const Database = require('../Database')
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
*/
class ToolsController {
@ -18,7 +23,7 @@ class ToolsController {
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async encodeM4b(req, res) {
@ -27,12 +32,12 @@ class ToolsController {
return res.status(404).send('Audiobook not found')
}
if (req.libraryItem.mediaType !== 'book') {
if (!req.libraryItem.isBook) {
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
return res.status(400).send('Invalid library item: not a book')
}
if (req.libraryItem.media.tracks.length <= 0) {
if (!req.libraryItem.hasAudioTracks) {
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
return res.status(400).send('Invalid audiobook: no audio tracks')
}
@ -72,11 +77,11 @@ class ToolsController {
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async embedAudioFileMetadata(req, res) {
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioTracks || !req.libraryItem.isBook) {
Logger.error(`[ToolsController] Invalid library item`)
return res.sendStatus(400)
}
@ -111,7 +116,7 @@ class ToolsController {
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
if (!libraryItem) {
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
return res.sendStatus(404)
@ -123,7 +128,7 @@ class ToolsController {
return res.sendStatus(403)
}
if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) {
if (libraryItem.isMissing || !libraryItem.hasAudioTracks || !libraryItem.isBook) {
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
return res.sendStatus(400)
}
@ -157,7 +162,7 @@ class ToolsController {
}
if (req.params.id) {
const item = await Database.libraryItemModel.getOldById(req.params.id)
const item = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!item?.media) return res.sendStatus(404)
// Check user can access this library item

View file

@ -361,7 +361,7 @@ class BookFinder {
/**
* Search for books including fuzzy searches
*
* @param {Object} libraryItem
* @param {import('../models/LibraryItem')} libraryItem
* @param {string} provider
* @param {string} title
* @param {string} author

View file

@ -51,7 +51,7 @@ class AbMergeManager {
/**
*
* @param {string} userId
* @param {import('../objects/LibraryItem')} libraryItem
* @param {import('../models/LibraryItem')} libraryItem
* @param {AbMergeEncodeOptions} [options={}]
*/
async startAudiobookMerge(userId, libraryItem, options = {}) {
@ -67,7 +67,7 @@ class AbMergeManager {
libraryItemId: libraryItem.id,
libraryItemDir,
userId,
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
originalTrackPaths: libraryItem.media.includedAudioFiles.map((t) => t.metadata.path),
inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
tempFilepath,
targetFilename,
@ -86,9 +86,9 @@ class AbMergeManager {
key: 'MessageTaskEncodingM4b'
}
const taskDescriptionString = {
text: `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`,
text: `Encoding audiobook "${libraryItem.media.title}" into a single m4b file.`,
key: 'MessageTaskEncodingM4bDescription',
subs: [libraryItem.media.metadata.title]
subs: [libraryItem.media.title]
}
task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData)
TaskManager.addTask(task)
@ -103,7 +103,7 @@ class AbMergeManager {
/**
*
* @param {import('../objects/LibraryItem')} libraryItem
* @param {import('../models/LibraryItem')} libraryItem
* @param {Task} task
* @param {AbMergeEncodeOptions} encodingOptions
*/
@ -141,7 +141,7 @@ class AbMergeManager {
const embedFraction = 1 - encodeFraction
try {
const trackProgressMonitor = new TrackProgressMonitor(
libraryItem.media.tracks.map((t) => t.duration),
libraryItem.media.includedAudioFiles.map((t) => t.duration),
(trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),
(trackIndex, progressInTrack, taskProgress) => {
SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
@ -150,7 +150,7 @@ class AbMergeManager {
(trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
)
task.data.ffmpeg = new Ffmpeg()
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.includedAudioFiles, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
delete task.data.ffmpeg
trackProgressMonitor.finish()
} catch (error) {

View file

@ -42,6 +42,8 @@ class ApiCacheManager {
Logger.debug(`[ApiCacheManager] Skipping cache for random sort`)
return next()
}
// Force URL to be lower case for matching against routes
req.url = req.url.toLowerCase()
const key = { user: req.user.username, url: req.url }
const stringifiedKey = JSON.stringify(key)
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)

View file

@ -34,6 +34,11 @@ class AudioMetadataMangaer {
return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId)
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @returns
*/
getMetadataObjectForApi(libraryItem) {
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
}
@ -41,8 +46,8 @@ class AudioMetadataMangaer {
/**
*
* @param {string} userId
* @param {*} libraryItems
* @param {*} options
* @param {import('../models/LibraryItem')[]} libraryItems
* @param {UpdateMetadataOptions} options
*/
handleBatchEmbed(userId, libraryItems, options = {}) {
libraryItems.forEach((li) => {
@ -53,7 +58,7 @@ class AudioMetadataMangaer {
/**
*
* @param {string} userId
* @param {import('../objects/LibraryItem')} libraryItem
* @param {import('../models/LibraryItem')} libraryItem
* @param {UpdateMetadataOptions} [options={}]
*/
async updateMetadataForItem(userId, libraryItem, options = {}) {
@ -103,14 +108,14 @@ class AudioMetadataMangaer {
key: 'MessageTaskEmbeddingMetadata'
}
const taskDescriptionString = {
text: `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`,
text: `Embedding metadata in audiobook "${libraryItem.media.title}".`,
key: 'MessageTaskEmbeddingMetadataDescription',
subs: [libraryItem.media.metadata.title]
subs: [libraryItem.media.title]
}
task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData)
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`)
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.title}"`)
SocketAuthority.adminEmitter('metadata_embed_queue_update', {
libraryItemId: libraryItem.id,
queued: true

View file

@ -79,6 +79,12 @@ class CoverManager {
return imgType
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} coverFile - file object from req.files
* @returns {Promise<{error:string}|{cover:string}>}
*/
async uploadCover(libraryItem, coverFile) {
const extname = Path.extname(coverFile.name.toLowerCase())
if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {
@ -110,62 +116,19 @@ class CoverManager {
await this.removeOldCovers(coverDirPath, extname)
await CacheManager.purgeCoverCache(libraryItem.id)
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.title}"`)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
}
}
async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) {
try {
// Force save cover with library item is used for adding new podcasts
var coverDirPath = forceLibraryItemFolder ? libraryItem.path : this.getCoverDirectory(libraryItem)
await fs.ensureDir(coverDirPath)
var temppath = Path.posix.join(coverDirPath, 'cover')
let errorMsg = ''
let success = await downloadImageFile(url, temppath)
.then(() => true)
.catch((err) => {
errorMsg = err.message || 'Unknown error'
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
return false
})
if (!success) {
return {
error: 'Failed to download image from url: ' + errorMsg
}
}
var imgtype = await this.checkFileIsValidImage(temppath, true)
if (imgtype.error) {
return imgtype
}
var coverFilename = `cover.${imgtype.ext}`
var coverFullPath = Path.posix.join(coverDirPath, coverFilename)
await fs.rename(temppath, coverFullPath)
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
await CacheManager.purgeCoverCache(libraryItem.id)
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
}
} catch (error) {
Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error)
return {
error: 'Failed to fetch image from url'
}
}
}
/**
*
* @param {string} coverPath
* @param {import('../models/LibraryItem')} libraryItem
* @returns {Promise<{error:string}|{cover:string,updated:boolean}>}
*/
async validateCoverPath(coverPath, libraryItem) {
// Invalid cover path
if (!coverPath || coverPath.startsWith('http:') || coverPath.startsWith('https:')) {
@ -235,7 +198,6 @@ class CoverManager {
await CacheManager.purgeCoverCache(libraryItem.id)
libraryItem.updateMediaCover(coverPath)
return {
cover: coverPath,
updated: true
@ -321,13 +283,14 @@ class CoverManager {
*
* @param {string} url
* @param {string} libraryItemId
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
* @param {string} [libraryItemPath] - null if library item isFile
* @param {boolean} [forceLibraryItemFolder=false] - force save cover with library item (used for adding new podcasts)
* @returns {Promise<{error:string}|{cover:string}>}
*/
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) {
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath, forceLibraryItemFolder = false) {
try {
let coverDirPath = null
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
if ((global.ServerSettings.storeCoverWithItem || forceLibraryItemFolder) && libraryItemPath) {
coverDirPath = libraryItemPath
} else {
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)

View file

@ -181,7 +181,7 @@ class CronManager {
// Get podcast library items to check
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out
@ -215,6 +215,10 @@ class CronManager {
this.podcastCrons = this.podcastCrons.filter((pc) => pc.expression !== podcastCron.expression)
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
*/
checkUpdatePodcastCron(libraryItem) {
// Remove from old cron by library item id
const existingCron = this.podcastCrons.find((pc) => pc.libraryItemIds.includes(libraryItem.id))
@ -230,7 +234,10 @@ class CronManager {
const cronMatchingExpression = this.podcastCrons.find((pc) => pc.expression === libraryItem.media.autoDownloadSchedule)
if (cronMatchingExpression) {
cronMatchingExpression.libraryItemIds.push(libraryItem.id)
Logger.info(`[CronManager] Added podcast "${libraryItem.media.metadata.title}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
// TODO: Update after old model removed
const podcastTitle = libraryItem.media.title || libraryItem.media.metadata?.title
Logger.info(`[CronManager] Added podcast "${podcastTitle}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
} else {
this.startPodcastCron(libraryItem.media.autoDownloadSchedule, [libraryItem.id])
}

View file

@ -14,6 +14,11 @@ class NotificationManager {
return notificationData
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../models/PodcastEpisode')} episode
*/
async onPodcastEpisodeDownloaded(libraryItem, episode) {
if (!Database.notificationSettings.isUseable) return
@ -22,17 +27,17 @@ class NotificationManager {
return
}
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.title}`)
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
const eventData = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
libraryName: library?.name || 'Unknown',
mediaTags: (libraryItem.media.tags || []).join(', '),
podcastTitle: libraryItem.media.metadata.title,
podcastAuthor: libraryItem.media.metadata.author || '',
podcastDescription: libraryItem.media.metadata.description || '',
podcastGenres: (libraryItem.media.metadata.genres || []).join(', '),
podcastTitle: libraryItem.media.title,
podcastAuthor: libraryItem.media.author || '',
podcastDescription: libraryItem.media.description || '',
podcastGenres: (libraryItem.media.genres || []).join(', '),
episodeId: episode.id,
episodeTitle: episode.title,
episodeSubtitle: episode.subtitle || '',

View file

@ -39,7 +39,7 @@ class PlaybackSessionManager {
/**
*
* @param {import('../controllers/SessionController').RequestWithUser} req
* @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
* @param {Object} [clientDeviceInfo]
* @returns {Promise<DeviceInfo>}
*/
@ -67,7 +67,7 @@ class PlaybackSessionManager {
/**
*
* @param {import('../controllers/SessionController').RequestWithUser} req
* @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
* @param {import('express').Response} res
* @param {string} [episodeId]
*/
@ -120,8 +120,8 @@ class PlaybackSessionManager {
*/
async syncLocalSession(user, sessionJson, deviceInfo) {
// TODO: Combine libraryItem query with library query
const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
const libraryItem = await Database.libraryItemModel.getExpandedById(sessionJson.libraryItemId)
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.podcastEpisodes.find((pe) => pe.id === sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
return {
@ -187,7 +187,8 @@ class PlaybackSessionManager {
if(session.displayAuthor == null || session.displayAuthor === '') {
session.displayAuthor = libraryItem?.media?.metadata?.authors?.map(a => a.name).join(', ') ?? libraryItem?.media?.metadata?.author ?? ''
}
session.setDuration(libraryItem, sessionJson.episodeId)
session.duration = libraryItem.media.getPlaybackDuration(sessionJson.episodeId)
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
await Database.createPlaybackSession(session)
} else {
@ -291,7 +292,7 @@ class PlaybackSessionManager {
*
* @param {import('../models/User')} user
* @param {DeviceInfo} deviceInfo
* @param {import('../objects/LibraryItem')} libraryItem
* @param {import('../models/LibraryItem')} libraryItem
* @param {string|null} episodeId
* @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options
* @returns {Promise<PlaybackSession>}
@ -304,7 +305,7 @@ class PlaybackSessionManager {
await this.closeSession(user, session, null)
}
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options.supportedMimeTypes, episodeId))
const mediaPlayer = options.mediaPlayer || 'unknown'
const mediaItemId = episodeId || libraryItem.media.id
@ -312,7 +313,7 @@ class PlaybackSessionManager {
let userStartTime = 0
if (userProgress) {
if (userProgress.isFinished) {
Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.metadata.title}"`)
Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.title}"`)
// Keep userStartTime as 0 so the client restarts the media
} else {
userStartTime = Number.parseFloat(userProgress.currentTime) || 0
@ -324,7 +325,7 @@ class PlaybackSessionManager {
let audioTracks = []
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
audioTracks = libraryItem.getTrackList(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
@ -354,20 +355,20 @@ class PlaybackSessionManager {
* @param {import('../models/User')} user
* @param {*} session
* @param {*} syncData
* @returns
* @returns {Promise<boolean>}
*/
async syncSession(user, session, syncData) {
// TODO: Combine libraryItem query with library query
const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null
return false
}
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
if (!library) {
Logger.error(`[PlaybackSessionManager] syncSession Library not found "${libraryItem.libraryId}"`)
return null
return false
}
session.currentTime = syncData.currentTime
@ -393,9 +394,8 @@ class PlaybackSessionManager {
})
}
this.saveSession(session)
return {
libraryItem
}
return true
}
/**

View file

@ -1,3 +1,4 @@
const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
@ -19,13 +20,13 @@ const NotificationManager = require('../managers/NotificationManager')
const LibraryFile = require('../objects/files/LibraryFile')
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
const AudioFile = require('../objects/files/AudioFile')
const LibraryItem = require('../objects/LibraryItem')
class PodcastManager {
constructor() {
/** @type {PodcastEpisodeDownload[]} */
this.downloadQueue = []
/** @type {PodcastEpisodeDownload} */
this.currentDownload = null
this.failedCheckMap = {}
@ -50,19 +51,25 @@ class PodcastManager {
}
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../utils/podcastUtils').RssPodcastEpisode[]} episodesToDownload
* @param {boolean} isAutoDownload - If this download was triggered by auto download
*/
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
let index = Math.max(...libraryItem.media.episodes.filter((ep) => ep.index == null || isNaN(ep.index)).map((ep) => Number(ep.index))) + 1
for (const ep of episodesToDownload) {
const newPe = new PodcastEpisode()
newPe.setData(ep, index++)
newPe.libraryItemId = libraryItem.id
newPe.podcastId = libraryItem.media.id
const newPeDl = new PodcastEpisodeDownload()
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
newPeDl.setData(ep, libraryItem, isAutoDownload, libraryItem.libraryId)
this.startPodcastEpisodeDownload(newPeDl)
}
}
/**
*
* @param {PodcastEpisodeDownload} podcastEpisodeDownload
* @returns
*/
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
if (this.currentDownload) {
this.downloadQueue.push(podcastEpisodeDownload)
@ -79,20 +86,20 @@ class PodcastManager {
key: 'MessageDownloadingEpisode'
}
const taskDescriptionString = {
text: `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`,
text: `Downloading episode "${podcastEpisodeDownload.episodeTitle}".`,
key: 'MessageTaskDownloadingEpisodeDescription',
subs: [podcastEpisodeDownload.podcastEpisode.title]
subs: [podcastEpisodeDownload.episodeTitle]
}
const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData)
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload
// If this file already exists then append the episode id to the filename
// If this file already exists then append a uuid to the filename
// e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3"
// this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
if (await fs.pathExists(this.currentDownload.targetPath)) {
this.currentDownload.appendEpisodeId = true
this.currentDownload.appendRandomId = true
}
// Ignores all added files to this dir
@ -106,7 +113,7 @@ class PodcastManager {
}
let success = false
if (this.currentDownload.urlFileExtension === 'mp3') {
if (this.currentDownload.isMp3) {
// Download episode and tag it
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
@ -133,7 +140,7 @@ class PodcastManager {
}
task.setFailed(taskFailedString)
} else {
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.episodeTitle}"`)
this.currentDownload.setFinished(true)
task.setFinished()
}
@ -159,47 +166,61 @@ class PodcastManager {
}
}
/**
* Scans the downloaded audio file, create the podcast episode, remove oldest episode if necessary
* @returns {Promise<boolean>} - Returns true if added
*/
async scanAddPodcastEpisodeAudioFile() {
const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
const libraryFile = new LibraryFile()
await libraryFile.setDataFromPath(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
const audioFile = await this.probeAudioFile(libraryFile)
if (!audioFile) {
return false
}
const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id)
const libraryItem = await Database.libraryItemModel.getExpandedById(this.currentDownload.libraryItem.id)
if (!libraryItem) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
return false
}
const podcastEpisode = this.currentDownload.podcastEpisode
podcastEpisode.audioFile = audioFile
const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile)
if (audioFile.chapters?.length) {
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))
}
libraryItem.libraryFiles.push(libraryFile.toJSON())
libraryItem.changed('libraryFiles', true)
libraryItem.media.addPodcastEpisode(podcastEpisode)
if (libraryItem.isInvalid) {
// First episode added to an empty podcast
libraryItem.isInvalid = false
}
libraryItem.libraryFiles.push(libraryFile)
libraryItem.media.podcastEpisodes.push(podcastEpisode)
if (this.currentDownload.isAutoDownload) {
// Check setting maxEpisodesToKeep and remove episode if necessary
if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) {
Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
await this.removeOldestEpisode(libraryItem, podcastEpisode.id)
const numEpisodesWithPubDate = libraryItem.media.podcastEpisodes.filter((ep) => !!ep.publishedAt).length
if (libraryItem.media.maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem.media.maxEpisodesToKeep) {
Logger.info(`[PodcastManager] # of episodes (${numEpisodesWithPubDate}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
const episodeToRemove = await this.getRemoveOldestEpisode(libraryItem, podcastEpisode.id)
if (episodeToRemove) {
// Remove episode from playlists
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])
// Remove media progress for this episode
await Database.mediaProgressModel.destroy({
where: {
mediaItemId: episodeToRemove.id
}
})
await episodeToRemove.destroy()
libraryItem.media.podcastEpisodes = libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeToRemove.id)
// Remove library file
libraryItem.libraryFiles = libraryItem.libraryFiles.filter((lf) => lf.ino !== episodeToRemove.audioFile.ino)
}
}
}
libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded()
podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded()
await libraryItem.save()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
SocketAuthority.emitter('episode_added', podcastEpisodeExpanded)
if (this.currentDownload.isAutoDownload) {
@ -210,45 +231,53 @@ class PodcastManager {
return true
}
async removeOldestEpisode(libraryItem, episodeIdJustDownloaded) {
var smallestPublishedAt = 0
var oldestEpisode = null
libraryItem.media.episodesWithPubDate
.filter((ep) => ep.id !== episodeIdJustDownloaded)
.forEach((ep) => {
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
smallestPublishedAt = ep.publishedAt
oldestEpisode = ep
}
})
// TODO: Should we check for open playback sessions for this episode?
// TODO: remove all user progress for this episode
/**
* Find oldest episode publishedAt and delete the audio file
*
* @param {import('../models/LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} episodeIdJustDownloaded
* @returns {Promise<import('../models/PodcastEpisode')|null>} - Returns the episode to remove
*/
async getRemoveOldestEpisode(libraryItem, episodeIdJustDownloaded) {
let smallestPublishedAt = 0
/** @type {import('../models/PodcastEpisode')} */
let oldestEpisode = null
/** @type {import('../models/PodcastEpisode')[]} */
const podcastEpisodes = libraryItem.media.podcastEpisodes
for (const ep of podcastEpisodes) {
if (ep.id === episodeIdJustDownloaded || !ep.publishedAt) continue
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
smallestPublishedAt = ep.publishedAt
oldestEpisode = ep
}
}
if (oldestEpisode?.audioFile) {
Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`)
const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path)
if (successfullyDeleted) {
libraryItem.media.removeEpisode(oldestEpisode.id)
libraryItem.removeLibraryFile(oldestEpisode.audioFile.ino)
return true
return oldestEpisode
} else {
Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`)
}
}
return false
}
async getLibraryFile(path, relPath) {
var newLibFile = new LibraryFile()
await newLibFile.setDataFromPath(path, relPath)
return newLibFile
return null
}
/**
*
* @param {LibraryFile} libraryFile
* @returns {Promise<AudioFile|null>}
*/
async probeAudioFile(libraryFile) {
const path = libraryFile.metadata.path
const mediaProbeData = await prober.probe(path)
if (mediaProbeData.error) {
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
return false
return null
}
const newAudioFile = new AudioFile()
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
@ -256,18 +285,23 @@ class PodcastManager {
return newAudioFile
}
// Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @returns {Promise<boolean>} - Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
*/
async runEpisodeCheck(libraryItem) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
const latestEpisodePublishedAt = libraryItem.media.getLatestEpisodePublishedAt()
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
// lastEpisodeCheckDate will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" | Last check: ${new Date(lastEpisodeCheck)} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheck
// lastEpisodeCheck will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheck
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`)
if (!newEpisodes) {
@ -276,37 +310,48 @@ class PodcastManager {
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
this.failedCheckMap[libraryItem.id]++
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`)
libraryItem.media.autoDownloadEpisodes = false
delete this.failedCheckMap[libraryItem.id]
} else {
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`)
}
} else if (newEpisodes.length) {
delete this.failedCheckMap[libraryItem.id]
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`)
this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)
} else {
delete this.failedCheckMap[libraryItem.id]
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.title}"`)
}
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
libraryItem.media.lastEpisodeCheck = new Date()
await libraryItem.media.save()
libraryItem.changed('updatedAt', true)
await libraryItem.save()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
return libraryItem.media.autoDownloadEpisodes
}
/**
*
* @param {import('../models/LibraryItem')} podcastLibraryItem
* @param {number} dateToCheckForEpisodesAfter - Unix timestamp
* @param {number} maxNewEpisodes
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]|null>}
*/
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {
if (!podcastLibraryItem.media.metadata.feedUrl) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
return false
if (!podcastLibraryItem.media.feedURL) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
return null
}
const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
if (!feed?.episodes) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
return false
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
return null
}
// Filter new and not already has
@ -319,23 +364,34 @@ class PodcastManager {
return newEpisodes
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} maxEpisodesToDownload
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]>}
*/
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload)
if (newEpisodes.length) {
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never'
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`)
const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload)
if (newEpisodes?.length) {
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`)
this.downloadPodcastEpisodes(libraryItem, newEpisodes, false)
} else {
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.title}"`)
}
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
libraryItem.media.lastEpisodeCheck = new Date()
await libraryItem.media.save()
return newEpisodes
libraryItem.changed('updatedAt', true)
await libraryItem.save()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
return newEpisodes || []
}
async findEpisode(rssFeedUrl, searchTitle) {
@ -511,64 +567,123 @@ class PodcastManager {
continue
}
const newPodcastMetadata = {
title: feed.metadata.title,
author: feed.metadata.author,
description: feed.metadata.description,
releaseDate: '',
genres: [...feed.metadata.categories],
feedUrl: feed.metadata.feedUrl,
imageUrl: feed.metadata.image,
itunesPageUrl: '',
itunesId: '',
itunesArtistId: '',
language: '',
numEpisodes: feed.numEpisodes
}
let newLibraryItem = null
const transaction = await Database.sequelize.transaction()
try {
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
const libraryItemPayload = {
path: podcastPath,
relPath: podcastFilename,
folderId: folder.id,
libraryId: folder.libraryId,
ino: libraryItemFolderStats.ino,
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
media: {
metadata: newPodcastMetadata,
autoDownloadEpisodes
const podcastPayload = {
autoDownloadEpisodes,
metadata: {
title: feed.metadata.title,
author: feed.metadata.author,
description: feed.metadata.description,
releaseDate: '',
genres: [...feed.metadata.categories],
feedUrl: feed.metadata.feedUrl,
imageUrl: feed.metadata.image,
itunesPageUrl: '',
itunesId: '',
itunesArtistId: '',
language: '',
numEpisodes: feed.numEpisodes
}
}
const podcast = await Database.podcastModel.createFromRequest(podcastPayload, transaction)
newLibraryItem = await Database.libraryItemModel.create(
{
ino: libraryItemFolderStats.ino,
path: podcastPath,
relPath: podcastFilename,
mediaId: podcast.id,
mediaType: 'podcast',
isFile: false,
isMissing: false,
isInvalid: false,
mtime: libraryItemFolderStats.mtimeMs || 0,
ctime: libraryItemFolderStats.ctimeMs || 0,
birthtime: libraryItemFolderStats.birthtimeMs || 0,
size: 0,
libraryFiles: [],
extraData: {},
libraryId: folder.libraryId,
libraryFolderId: folder.id
},
{ transaction }
)
await transaction.commit()
} catch (error) {
await transaction.rollback()
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast library item for "${feed.metadata.title}"`, error)
const taskTitleStringFeed = {
text: 'OPML import feed',
key: 'MessageTaskOpmlImportFeed'
}
const taskDescriptionStringPodcast = {
text: `Creating podcast "${feed.metadata.title}"`,
key: 'MessageTaskOpmlImportFeedPodcastDescription',
subs: [feed.metadata.title]
}
const taskErrorString = {
text: 'Failed to create podcast library item',
key: 'MessageTaskOpmlImportFeedPodcastFailed'
}
TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString)
continue
}
const libraryItem = new LibraryItem()
libraryItem.setData('podcast', libraryItemPayload)
newLibraryItem.media = await newLibraryItem.getMediaExpanded()
// Download and save cover image
if (newPodcastMetadata.imageUrl) {
// TODO: Scan cover image to library files
if (typeof feed.metadata.image === 'string' && feed.metadata.image.startsWith('http')) {
// Podcast cover will always go into library item folder
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true)
if (coverResponse) {
if (coverResponse.error) {
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`)
} else if (coverResponse.cover) {
libraryItem.media.coverPath = coverResponse.cover
const coverResponse = await CoverManager.downloadCoverFromUrlNew(feed.metadata.image, newLibraryItem.id, newLibraryItem.path, true)
if (coverResponse.error) {
Logger.error(`[PodcastManager] Download cover error from "${feed.metadata.image}": ${coverResponse.error}`)
} else if (coverResponse.cover) {
const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
if (!coverImageFileStats) {
Logger.error(`[PodcastManager] Failed to get cover image stats for "${coverResponse.cover}"`)
} else {
// Add libraryFile to libraryItem and coverPath to podcast
const newLibraryFile = {
ino: coverImageFileStats.ino,
fileType: 'image',
addedAt: Date.now(),
updatedAt: Date.now(),
metadata: {
filename: Path.basename(coverResponse.cover),
ext: Path.extname(coverResponse.cover).slice(1),
path: coverResponse.cover,
relPath: Path.basename(coverResponse.cover),
size: coverImageFileStats.size,
mtimeMs: coverImageFileStats.mtimeMs || 0,
ctimeMs: coverImageFileStats.ctimeMs || 0,
birthtimeMs: coverImageFileStats.birthtimeMs || 0
}
}
newLibraryItem.libraryFiles.push(newLibraryFile)
newLibraryItem.changed('libraryFiles', true)
await newLibraryItem.save()
newLibraryItem.media.coverPath = coverResponse.cover
await newLibraryItem.media.save()
}
}
}
await Database.createLibraryItem(libraryItem)
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
// Turn on podcast auto download cron if not already on
if (libraryItem.media.autoDownloadEpisodes) {
cronManager.checkUpdatePodcastCron(libraryItem)
if (newLibraryItem.media.autoDownloadEpisodes) {
cronManager.checkUpdatePodcastCron(newLibraryItem)
}
numPodcastsAdded++
}
const taskFinishedString = {
text: `Added ${numPodcastsAdded} podcasts`,
key: 'MessageTaskOpmlImportFinished',

View file

@ -1,3 +1,4 @@
const { Request, Response } = require('express')
const Path = require('path')
const Logger = require('../Logger')
@ -5,170 +6,205 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const fs = require('../libs/fsExtra')
const Feed = require('../objects/Feed')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RssFeedManager {
constructor() {}
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
if (!collection) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'libraryItem') {
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
if (!libraryItemExists) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'series') {
const series = await Database.seriesModel.findByPk(feedObj.entityId)
if (!series) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
return false
}
} else {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`)
return false
}
return true
}
/**
* Validate all feeds and remove invalid
* Remove invalid feeds (invalid if the entity does not exist)
*/
async init() {
const feeds = await Database.feedModel.getOldFeeds()
const feeds = await Database.feedModel.findAll({
attributes: ['id', 'entityId', 'entityType', 'title'],
include: [
{
model: Database.libraryItemModel,
attributes: ['id']
},
{
model: Database.collectionModel,
attributes: ['id']
},
{
model: Database.seriesModel,
attributes: ['id']
}
]
})
const feedIdsToRemove = []
for (const feed of feeds) {
// Remove invalid feeds
if (!(await this.validateFeedEntity(feed))) {
await Database.removeFeed(feed.id)
if (!feed.entity) {
Logger.error(`[RssFeedManager] Removing feed "${feed.title}". Entity not found`)
feedIdsToRemove.push(feed.id)
}
}
if (feedIdsToRemove.length) {
Logger.info(`[RssFeedManager] Removing ${feedIdsToRemove.length} invalid feeds`)
await Database.feedModel.destroy({
where: {
id: feedIdsToRemove
}
})
}
}
/**
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
* @param {string} entityId
* @returns {Promise<objects.Feed>} oldFeed
* @returns {Promise<import('../models/Feed')>}
*/
findFeedForEntityId(entityId) {
return Database.feedModel.findOneOld({ entityId })
return Database.feedModel.findOne({
where: {
entityId
}
})
}
/**
* Find open feed for a slug
*
* @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed
* @returns {Promise<boolean>}
*/
findFeedBySlug(slug) {
return Database.feedModel.findOneOld({ slug })
checkExistsBySlug(slug) {
return Database.feedModel
.count({
where: {
slug
}
})
.then((count) => count > 0)
}
/**
* Find open feed for a slug
* @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed
* Feed requires update if the entity (or child entities) has been updated since the feed was last updated
*
* @param {import('../models/Feed')} feed
* @returns {Promise<boolean>}
*/
findFeed(id) {
return Database.feedModel.findByPkOld(id)
async checkFeedRequiresUpdate(feed) {
if (feed.entityType === 'libraryItem') {
feed.entity = await feed.getEntity({
attributes: ['id', 'updatedAt', 'mediaId', 'mediaType']
})
let newEntityUpdatedAt = feed.entity.updatedAt
if (feed.entity.mediaType === 'podcast') {
const mostRecentPodcastEpisode = await Database.podcastEpisodeModel.findOne({
where: {
podcastId: feed.entity.mediaId
},
attributes: ['id', 'updatedAt'],
order: [['updatedAt', 'DESC']]
})
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
}
} else {
const book = await Database.bookModel.findOne({
where: {
id: feed.entity.mediaId
},
attributes: ['id', 'updatedAt']
})
if (book && book.updatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = book.updatedAt
}
}
return newEntityUpdatedAt > feed.entityUpdatedAt
} else if (feed.entityType === 'collection' || feed.entityType === 'series') {
feed.entity = await feed.getEntity({
attributes: ['id', 'updatedAt'],
include: {
model: Database.bookModel,
attributes: ['id', 'audioFiles', 'updatedAt'],
through: {
attributes: []
},
include: {
model: Database.libraryItemModel,
attributes: ['id', 'updatedAt']
}
}
})
const totalBookTracks = feed.entity.books.reduce((total, book) => total + book.includedAudioFiles.length, 0)
if (feed.feedEpisodes.length !== totalBookTracks) {
return true
}
let newEntityUpdatedAt = feed.entity.updatedAt
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
let updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
return updatedAt > mostRecent ? updatedAt : mostRecent
}, 0)
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentItemUpdatedAt
}
return newEntityUpdatedAt > feed.entityUpdatedAt
} else {
throw new Error('Invalid feed entity type')
}
}
/**
* GET: /feed/:slug
*
* @param {Request} req
* @param {Response} res
*/
async getFeed(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
let feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
include: {
model: Database.feedEpisodeModel
}
})
if (!feed) {
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
return
}
// Check if feed needs to be updated
if (feed.entityType === 'libraryItem') {
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
let mostRecentlyUpdatedAt = libraryItem.updatedAt
if (libraryItem.isPodcast) {
libraryItem.media.episodes.forEach((episode) => {
if (episode.updatedAt > mostRecentlyUpdatedAt) mostRecentlyUpdatedAt = episode.updatedAt
})
}
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
feed.updateFromItem(libraryItem)
await Database.updateFeed(feed)
}
} else if (feed.entityType === 'collection') {
const collection = await Database.collectionModel.findByPk(feed.entityId, {
include: Database.collectionBookModel
})
if (collection) {
const collectionExpanded = await collection.getOldJsonExpanded()
// Find most recently updated item in collection
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
// Check for most recently updated book
collectionExpanded.books.forEach((libraryItem) => {
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = libraryItem.updatedAt
}
})
// Check for most recently added collection book
collection.collectionBooks.forEach((collectionBook) => {
if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf()
}
})
const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length
if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
feed.updateFromCollection(collectionExpanded)
await Database.updateFeed(feed)
}
}
} else if (feed.entityType === 'series') {
const series = await Database.seriesModel.findByPk(feed.entityId)
if (series) {
const seriesJson = series.toOldJSON()
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Find most recently updated item in series
let mostRecentlyUpdatedAt = seriesJson.updatedAt
let totalTracks = 0 // Used to detect series items removed
seriesJson.books.forEach((libraryItem) => {
totalTracks += libraryItem.media.tracks.length
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = libraryItem.updatedAt
}
})
if (totalTracks !== feed.episodes.length) {
mostRecentlyUpdatedAt = Date.now()
}
if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
Logger.debug(`[RssFeedManager] Updating RSS feed for series "${seriesJson.name}"`)
feed.updateFromSeries(seriesJson)
await Database.updateFeed(feed)
}
}
const feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)
if (feedRequiresUpdate) {
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
feed = await feed.updateFeedForEntity()
}
const xml = feed.buildXml()
const xml = feed.buildXml(req.originalHostPrefix)
res.set('Content-Type', 'text/xml')
res.send(xml)
}
/**
* GET: /feed/:slug/item/:episodeId/*
*
* @param {Request} req
* @param {Response} res
*/
async getFeedItem(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
const feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
attributes: ['id', 'slug'],
include: {
model: Database.feedEpisodeModel,
attributes: ['id', 'filePath']
}
})
if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
@ -183,8 +219,19 @@ class RssFeedManager {
res.sendFile(episodePath)
}
/**
* GET: /feed/:slug/cover*
*
* @param {Request} req
* @param {Response} res
*/
async getFeedCover(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
const feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
attributes: ['coverPath']
})
if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
@ -204,100 +251,142 @@ class RssFeedManager {
/**
*
* @param {string} userId
* @param {*} libraryItem
* @param {*} options
* @returns
* @returns {import('../models/Feed').FeedOptions}
*/
getFeedOptionsFromReqOptions(options) {
const metadataDetails = options.metadataDetails || {}
if (metadataDetails.preventIndexing !== false) {
metadataDetails.preventIndexing = true
}
return {
preventIndexing: metadataDetails.preventIndexing,
ownerName: metadataDetails.ownerName && typeof metadataDetails.ownerName === 'string' ? metadataDetails.ownerName : null,
ownerEmail: metadataDetails.ownerEmail && typeof metadataDetails.ownerEmail === 'string' ? metadataDetails.ownerEmail : null
}
}
/**
*
* @param {string} userId
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} options
* @returns {Promise<import('../models/Feed').FeedExpanded>}
*/
async openFeedForItem(userId, libraryItem, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feedOptions = this.getFeedOptionsFromReqOptions(options)
const feed = new Feed()
feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
return feed
Logger.info(`[RssFeedManager] Creating RSS feed for item ${libraryItem.id} "${libraryItem.media.title}"`)
const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
}
return feedExpanded
}
/**
*
* @param {string} userId
* @param {*} collectionExpanded
* @param {import('../models/Collection')} collectionExpanded
* @param {*} options
* @returns
* @returns {Promise<import('../models/Feed').FeedExpanded>}
*/
async openFeedForCollection(userId, collectionExpanded, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feedOptions = this.getFeedOptionsFromReqOptions(options)
const feed = new Feed()
feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
return feed
Logger.info(`[RssFeedManager] Creating RSS feed for collection "${collectionExpanded.name}"`)
const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
}
return feedExpanded
}
/**
*
* @param {string} userId
* @param {*} seriesExpanded
* @param {import('../models/Series')} seriesExpanded
* @param {*} options
* @returns
* @returns {Promise<import('../models/Feed').FeedExpanded>}
*/
async openFeedForSeries(userId, seriesExpanded, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feedOptions = this.getFeedOptionsFromReqOptions(options)
const feed = new Feed()
feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
return feed
}
async handleCloseFeed(feed) {
if (!feed) return
await Database.removeFeed(feed.id)
SocketAuthority.emitter('rss_feed_closed', feed.toJSONMinified())
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`)
}
async closeRssFeed(req, res) {
const feed = await this.findFeed(req.params.id)
if (!feed) {
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
return res.sendStatus(404)
Logger.info(`[RssFeedManager] Creating RSS feed for series "${seriesExpanded.name}"`)
const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
}
await this.handleCloseFeed(feed)
res.sendStatus(200)
return feedExpanded
}
/**
* Close Feed and emit Socket event
*
* @param {import('../models/Feed')} feed
* @returns {Promise<boolean>} - true if feed was closed
*/
async handleCloseFeed(feed) {
if (!feed) return false
const wasRemoved = await Database.feedModel.removeById(feed.id)
SocketAuthority.emitter('rss_feed_closed', feed.toOldJSONMinified())
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedURL}"`)
return wasRemoved
}
/**
*
* @param {string} entityId
* @returns {Promise<boolean>} - true if feed was closed
*/
async closeFeedForEntityId(entityId) {
const feed = await this.findFeedForEntityId(entityId)
if (!feed) return
const feed = await Database.feedModel.findOne({
where: {
entityId
}
})
if (!feed) {
return false
}
return this.handleCloseFeed(feed)
}
async getFeeds() {
const feeds = await Database.models.feed.getOldFeeds()
Logger.info(`[RssFeedManager] Fetched all feeds`)
return feeds
/**
*
* @param {string[]} entityIds
*/
async closeFeedsForEntityIds(entityIds) {
const feeds = await Database.feedModel.findAll({
where: {
entityId: entityIds
}
})
for (const feed of feeds) {
await this.handleCloseFeed(feed)
}
}
/**
*
* @returns {Promise<import('../models/Feed').FeedExpanded[]>}
*/
getFeeds() {
return Database.feedModel.findAll({
include: {
model: Database.feedEpisodeModel
}
})
}
}
module.exports = RssFeedManager
module.exports = new RssFeedManager()

View file

@ -10,3 +10,6 @@ Please add a record of every database migration that you create to this file. Th
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table |
| v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times |

View file

@ -0,0 +1,74 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.17.5'
const migrationName = `${migrationVersion}-remove-host-from-feed-urls`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`)
await queryInterface.sequelize.query(`
UPDATE Feeds
SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''),
imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''),
siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), '');
`)
logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`)
logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`)
await queryInterface.sequelize.query(`
UPDATE FeedEpisodes
SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''),
enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), '');
`)
logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`)
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`)
await queryInterface.sequelize.query(`
UPDATE Feeds
SET feedUrl = COALESCE(serverAddress, '') || feedUrl,
imageUrl = COALESCE(serverAddress, '') || imageUrl,
siteUrl = COALESCE(serverAddress, '') || siteUrl;
`)
logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`)
logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`)
await queryInterface.sequelize.query(`
UPDATE FeedEpisodes
SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId),
enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId);
`)
logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`)
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

View file

@ -0,0 +1,68 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.17.6'
const migrationName = `${migrationVersion}-share-add-isdownloadable`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This migration script adds the isDownloadable column to the mediaItemShares table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('mediaItemShares')) {
const tableDescription = await queryInterface.describeTable('mediaItemShares')
if (!tableDescription.isDownloadable) {
logger.info(`${loggerPrefix} Adding isDownloadable column to mediaItemShares table`)
await queryInterface.addColumn('mediaItemShares', 'isDownloadable', {
type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false
})
logger.info(`${loggerPrefix} Added isDownloadable column to mediaItemShares table`)
} else {
logger.info(`${loggerPrefix} isDownloadable column already exists in mediaItemShares table`)
}
} else {
logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This migration script removes the isDownloadable column from the mediaItemShares table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('mediaItemShares')) {
const tableDescription = await queryInterface.describeTable('mediaItemShares')
if (tableDescription.isDownloadable) {
logger.info(`${loggerPrefix} Removing isDownloadable column from mediaItemShares table`)
await queryInterface.removeColumn('mediaItemShares', 'isDownloadable')
logger.info(`${loggerPrefix} Removed isDownloadable column from mediaItemShares table`)
} else {
logger.info(`${loggerPrefix} isDownloadable column does not exist in mediaItemShares table`)
}
} else {
logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

View file

@ -0,0 +1,83 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.17.7'
const migrationName = `${migrationVersion}-add-indices`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration adds some indices to the libraryItems and books tables to improve query performance
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
await addIndex(queryInterface, logger, 'books', ['duration'])
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration script removes the indices added in the upward migration script
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
await removeIndex(queryInterface, logger, 'books', ['duration'])
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
/**
* Utility function to add an index to a table. If the index already exists, it logs a message and continues.
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import ('../Logger')} logger
* @param {string} tableName
* @param {string[]} columns
*/
async function addIndex(queryInterface, logger, tableName, columns) {
try {
logger.info(`${loggerPrefix} adding index [${columns.join(', ')}] to table "${tableName}"`)
await queryInterface.addIndex(tableName, columns)
logger.info(`${loggerPrefix} added index [${columns.join(', ')}] to table "${tableName}"`)
} catch (error) {
if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {
logger.info(`${loggerPrefix} index [${columns.join(', ')}] for table "${tableName}" already exists`)
} else {
throw error
}
}
}
/**
* Utility function to remove an index from a table.
* Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist.
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import ('../Logger')} logger
* @param {string} tableName
* @param {string[]} columns
*/
async function removeIndex(queryInterface, logger, tableName, columns) {
logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`)
await queryInterface.removeIndex(tableName, columns)
logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`)
}
module.exports = { up, down }

View file

@ -107,6 +107,22 @@ class Author extends Model {
return libraryItems
}
/**
*
* @param {string} name
* @param {string} libraryId
* @returns {Promise<Author>}
*/
static async findOrCreateByNameAndLibrary(name, libraryId) {
const author = await this.getByNameAndLibrary(name, libraryId)
if (author) return author
return this.create({
name,
lastFirst: this.getLastFirst(name),
libraryId
})
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize

View file

@ -1,5 +1,7 @@
const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const parseNameString = require('../utils/parsers/parseNameString')
/**
* @typedef EBookFileObject
@ -29,6 +31,12 @@ const Logger = require('../Logger')
* @property {SeriesExpanded[]} series
*
* @typedef {Book & BookExpandedProperties} BookExpanded
*
* Collections use BookExpandedWithLibraryItem
* @typedef BookExpandedWithLibraryItemProperties
* @property {import('./LibraryItem')} libraryItem
*
* @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem
*/
/**
@ -54,6 +62,13 @@ const Logger = require('../Logger')
* @property {ChapterObject[]} chapters
* @property {Object} metaTags
* @property {string} mimeType
*
* @typedef AudioTrackProperties
* @property {string} title
* @property {string} contentUrl
* @property {number} startOffset
*
* @typedef {AudioFileObject & AudioTrackProperties} AudioTrack
*/
class Book extends Model {
@ -106,156 +121,13 @@ class Book extends Model {
this.updatedAt
/** @type {Date} */
this.createdAt
}
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)
}
// Expanded properties
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
}
}
}
/**
* @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
})
}
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
}
}
getAbsMetadataJson() {
return {
tags: this.tags || [],
chapters: this.chapters?.map((c) => ({ ...c })) || [],
title: this.title,
subtitle: this.subtitle,
authors: this.authors.map((a) => a.name),
narrators: this.narrators,
series: this.series.map((se) => {
const sequence = se.bookSeries?.sequence || ''
if (!sequence) return se.name
return `${se.name} #${sequence}`
}),
genres: this.genres || [],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: !!this.explicit,
abridged: !!this.abridged
}
/** @type {import('./Author')[]} - optional if expanded */
this.authors
/** @type {import('./Series')[]} - optional if expanded */
this.series
}
/**
@ -312,14 +184,481 @@ class Book extends Model {
// },
{
fields: ['publishedYear']
},
{
fields: ['duration']
}
// {
// fields: ['duration']
// }
]
}
)
}
/**
* Comma separated array of author names
* Requires authors to be loaded
*
* @returns {string}
*/
get authorName() {
if (this.authors === undefined) {
Logger.error(`[Book] authorName: Cannot get authorName because authors are not loaded`)
return ''
}
return this.authors.map((au) => au.name).join(', ')
}
/**
* Comma separated array of author names in Last, First format
* Requires authors to be loaded
*
* @returns {string}
*/
get authorNameLF() {
if (this.authors === undefined) {
Logger.error(`[Book] authorNameLF: Cannot get authorNameLF because authors are not loaded`)
return ''
}
// Last, First
if (!this.authors.length) return ''
return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')
}
/**
* Comma separated array of series with sequence
* Requires series to be loaded
*
* @returns {string}
*/
get seriesName() {
if (this.series === undefined) {
Logger.error(`[Book] seriesName: Cannot get seriesName because series are not loaded`)
return ''
}
if (!this.series.length) return ''
return this.series
.map((se) => {
const sequence = se.bookSeries?.sequence || ''
if (!sequence) return se.name
return `${se.name} #${sequence}`
})
.join(', ')
}
get includedAudioFiles() {
return this.audioFiles.filter((af) => !af.exclude)
}
get hasMediaFiles() {
return !!this.hasAudioTracks || !!this.ebookFile
}
get hasAudioTracks() {
return !!this.includedAudioFiles.length
}
/**
* Supported mime types are sent from the web client and are retrieved using the browser Audio player "canPlayType" function.
*
* @param {string[]} supportedMimeTypes
* @returns {boolean}
*/
checkCanDirectPlay(supportedMimeTypes) {
if (!Array.isArray(supportedMimeTypes)) {
Logger.error(`[Book] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes)
return false
}
return this.includedAudioFiles.every((af) => supportedMimeTypes.includes(af.mimeType))
}
/**
* Get the track list to be used in client audio players
* AudioTrack is the AudioFile with startOffset, contentUrl and title
*
* @param {string} libraryItemId
* @returns {AudioTrack[]}
*/
getTracklist(libraryItemId) {
let startOffset = 0
return this.includedAudioFiles.map((af) => {
const track = structuredClone(af)
track.title = af.metadata.filename
track.startOffset = startOffset
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
startOffset += track.duration
return track
})
}
/**
*
* @returns {ChapterObject[]}
*/
getChapters() {
return structuredClone(this.chapters) || []
}
getPlaybackTitle() {
return this.title
}
getPlaybackAuthor() {
return this.authorName
}
getPlaybackDuration() {
return this.duration
}
/**
* Total file size of all audio files and ebook file
*
* @returns {number}
*/
get size() {
let total = 0
this.audioFiles.forEach((af) => (total += af.metadata.size))
if (this.ebookFile) {
total += this.ebookFile.metadata.size
}
return total
}
getAbsMetadataJson() {
return {
tags: this.tags || [],
chapters: this.chapters?.map((c) => ({ ...c })) || [],
title: this.title,
subtitle: this.subtitle,
authors: this.authors.map((a) => a.name),
narrators: this.narrators,
series: this.series.map((se) => {
const sequence = se.bookSeries?.sequence || ''
if (!sequence) return se.name
return `${se.name} #${sequence}`
}),
genres: this.genres || [],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: !!this.explicit,
abridged: !!this.abridged
}
}
/**
*
* @param {Object} payload - old book object
* @returns {Promise<boolean>}
*/
async updateFromRequest(payload) {
if (!payload) return false
let hasUpdates = false
if (payload.metadata) {
const metadataStringKeys = ['title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language']
metadataStringKeys.forEach((key) => {
if (typeof payload.metadata[key] === 'string' && this[key] !== payload.metadata[key]) {
this[key] = payload.metadata[key] || null
if (key === 'title') {
this.titleIgnorePrefix = getTitleIgnorePrefix(this.title)
}
hasUpdates = true
}
})
if (payload.metadata.explicit !== undefined && this.explicit !== !!payload.metadata.explicit) {
this.explicit = !!payload.metadata.explicit
hasUpdates = true
}
if (payload.metadata.abridged !== undefined && this.abridged !== !!payload.metadata.abridged) {
this.abridged = !!payload.metadata.abridged
hasUpdates = true
}
const arrayOfStringsKeys = ['narrators', 'genres']
arrayOfStringsKeys.forEach((key) => {
if (Array.isArray(payload.metadata[key]) && !payload.metadata[key].some((item) => typeof item !== 'string') && JSON.stringify(this[key]) !== JSON.stringify(payload.metadata[key])) {
this[key] = payload.metadata[key]
this.changed(key, true)
hasUpdates = true
}
})
}
if (Array.isArray(payload.tags) && !payload.tags.some((tag) => typeof tag !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) {
this.tags = payload.tags
this.changed('tags', true)
hasUpdates = true
}
// TODO: Remove support for updating audioFiles, chapters and ebookFile here
const arrayOfObjectsKeys = ['audioFiles', 'chapters']
arrayOfObjectsKeys.forEach((key) => {
if (Array.isArray(payload[key]) && !payload[key].some((item) => typeof item !== 'object') && JSON.stringify(this[key]) !== JSON.stringify(payload[key])) {
this[key] = payload[key]
this.changed(key, true)
hasUpdates = true
}
})
if (payload.ebookFile && JSON.stringify(this.ebookFile) !== JSON.stringify(payload.ebookFile)) {
this.ebookFile = payload.ebookFile
this.changed('ebookFile', true)
hasUpdates = true
}
if (hasUpdates) {
Logger.debug(`[Book] "${this.title}" changed keys:`, this.changed())
await this.save()
}
return hasUpdates
}
/**
* Creates or removes authors from the book using the author names from the request
*
* @param {string[]} authors
* @param {string} libraryId
* @returns {Promise<{authorsRemoved: import('./Author')[], authorsAdded: import('./Author')[]}>}
*/
async updateAuthorsFromRequest(authors, libraryId) {
if (!Array.isArray(authors)) return null
if (!this.authors) {
throw new Error(`[Book] Cannot update authors because authors are not loaded for book ${this.id}`)
}
/** @type {typeof import('./Author')} */
const authorModel = this.sequelize.models.author
/** @type {typeof import('./BookAuthor')} */
const bookAuthorModel = this.sequelize.models.bookAuthor
const authorsCleaned = authors.map((a) => a.toLowerCase()).filter((a) => a)
const authorsRemoved = this.authors.filter((au) => !authorsCleaned.includes(au.name.toLowerCase()))
const newAuthorNames = authors.filter((a) => !this.authors.some((au) => au.name.toLowerCase() === a.toLowerCase()))
for (const author of authorsRemoved) {
await bookAuthorModel.removeByIds(author.id, this.id)
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
this.authors = this.authors.filter((au) => au.id !== author.id)
}
const authorsAdded = []
for (const authorName of newAuthorNames) {
const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
this.authors.push(author)
authorsAdded.push(author)
}
return {
authorsRemoved,
authorsAdded
}
}
/**
* Creates or removes series from the book using the series names from the request.
* Updates series sequence if it has changed.
*
* @param {{ name: string, sequence: string }[]} seriesObjects
* @param {string} libraryId
* @returns {Promise<{seriesRemoved: import('./Series')[], seriesAdded: import('./Series')[], hasUpdates: boolean}>}
*/
async updateSeriesFromRequest(seriesObjects, libraryId) {
if (!Array.isArray(seriesObjects) || seriesObjects.some((se) => !se.name || typeof se.name !== 'string')) return null
if (!this.series) {
throw new Error(`[Book] Cannot update series because series are not loaded for book ${this.id}`)
}
/** @type {typeof import('./Series')} */
const seriesModel = this.sequelize.models.series
/** @type {typeof import('./BookSeries')} */
const bookSeriesModel = this.sequelize.models.bookSeries
const seriesNamesCleaned = seriesObjects.map((se) => se.name.toLowerCase())
const seriesRemoved = this.series.filter((se) => !seriesNamesCleaned.includes(se.name.toLowerCase()))
const seriesAdded = []
let hasUpdates = false
for (const seriesObj of seriesObjects) {
const seriesObjSequence = typeof seriesObj.sequence === 'string' ? seriesObj.sequence : null
const existingSeries = this.series.find((se) => se.name.toLowerCase() === seriesObj.name.toLowerCase())
if (existingSeries) {
if (existingSeries.bookSeries.sequence !== seriesObjSequence) {
existingSeries.bookSeries.sequence = seriesObjSequence
await existingSeries.bookSeries.save()
hasUpdates = true
Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" sequence ${seriesObjSequence}`)
}
} else {
const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId)
series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence })
this.series.push(series)
seriesAdded.push(series)
hasUpdates = true
Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`)
}
}
for (const series of seriesRemoved) {
await bookSeriesModel.removeByIds(series.id, this.id)
this.series = this.series.filter((se) => se.id !== series.id)
Logger.debug(`[Book] "${this.title}" Removed series ${series.id}`)
hasUpdates = true
}
return {
seriesRemoved,
seriesAdded,
hasUpdates
}
}
/**
* Old model kept metadata in a separate object
*/
oldMetadataToJSON() {
const authors = this.authors.map((au) => ({ id: au.id, name: au.name }))
const series = this.series.map((se) => ({ id: se.id, name: se.name, sequence: se.bookSeries.sequence }))
return {
title: this.title,
subtitle: this.subtitle,
authors,
narrators: [...(this.narrators || [])],
series,
genres: [...(this.genres || [])],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
}
}
oldMetadataToJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: getTitlePrefixAtEnd(this.title),
subtitle: this.subtitle,
authorName: this.authorName,
authorNameLF: this.authorNameLF,
narratorName: (this.narrators || []).join(', '),
seriesName: this.seriesName,
genres: [...(this.genres || [])],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
}
}
oldMetadataToJSONExpanded() {
const oldMetadataJSON = this.oldMetadataToJSON()
oldMetadataJSON.titleIgnorePrefix = getTitlePrefixAtEnd(this.title)
oldMetadataJSON.authorName = this.authorName
oldMetadataJSON.authorNameLF = this.authorNameLF
oldMetadataJSON.narratorName = (this.narrators || []).join(', ')
oldMetadataJSON.seriesName = this.seriesName
return oldMetadataJSON
}
/**
* The old model stored a minified series and authors array with the book object.
* Minified series is { id, name, sequence }
* Minified author is { id, name }
*
* @param {string} libraryItemId
*/
toOldJSON(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
}
if (!this.authors) {
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
}
if (!this.series) {
throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSON(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
audioFiles: structuredClone(this.audioFiles),
chapters: structuredClone(this.chapters),
ebookFile: structuredClone(this.ebookFile)
}
}
toOldJSONMinified() {
if (!this.authors) {
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
}
if (!this.series) {
throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)
}
return {
id: this.id,
metadata: this.oldMetadataToJSONMinified(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
numTracks: this.includedAudioFiles.length,
numAudioFiles: this.audioFiles?.length || 0,
numChapters: this.chapters?.length || 0,
duration: this.duration,
size: this.size,
ebookFormat: this.ebookFile?.ebookFormat
}
}
toOldJSONExpanded(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
}
if (!this.authors) {
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
}
if (!this.series) {
throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
audioFiles: structuredClone(this.audioFiles),
chapters: structuredClone(this.chapters),
ebookFile: structuredClone(this.ebookFile),
duration: this.duration,
size: this.size,
tracks: this.getTracklist(libraryItemId)
}
}
}
module.exports = Book

View file

@ -1,7 +1,5 @@
const { DataTypes, Model, Sequelize } = require('sequelize')
const oldCollection = require('../objects/Collection')
class Collection extends Model {
constructor(values, options) {
super(values, options)
@ -18,15 +16,20 @@ class Collection extends Model {
this.updatedAt
/** @type {Date} */
this.createdAt
// Expanded properties
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
this.books
}
/**
* Get all old collections toJSONExpanded, items filtered for user permissions
* Get all toOldJSONExpanded, items filtered for user permissions
*
* @param {import('./User')} user
* @param {string} [libraryId]
* @param {string[]} [include]
* @returns {Promise<oldCollection[]>} oldCollection.toJSONExpanded
* @async
*/
static async getOldCollectionsJsonExpanded(user, libraryId, include) {
let collectionWhere = null
@ -74,8 +77,6 @@ class Collection extends Model {
// 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) => {
@ -90,24 +91,18 @@ class Collection extends Model {
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) {
if (!books.length && c.books.length) {
return null
}
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
this.books = books
const collectionExpanded = c.toOldJSONExpanded()
// Map feed if found
if (c.feeds?.length) {
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0])
collectionExpanded.rssFeed = c.feeds[0].toOldJSON()
}
return collectionExpanded
@ -115,67 +110,37 @@ class Collection extends Model {
.filter((c) => c)
}
/**
* 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()
})
}
/**
*
* @param {oldCollection} oldCollection
* @param {string} collectionId
* @returns {Promise<Collection>}
*/
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
}
}
static removeById(collectionId) {
return this.destroy({
where: {
id: collectionId
}
})
}
/**
* Get old collection by id
* @param {string} collectionId
* @returns {Promise<oldCollection|null>} 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
},
static async getExpandedById(collectionId) {
return this.findByPk(collectionId, {
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: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
})
if (!collection) return null
return this.getOldCollection(collection)
}
/**
@ -220,69 +185,70 @@ class Collection extends Model {
}
/**
* Get old collection toJSONExpanded, items filtered for user permissions
* Get all books in collection expanded with library item
*
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
*/
getBooksExpandedWithLibraryItem() {
return 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')]
})
}
/**
* Get toOldJSONExpanded, items filtered for user permissions
*
* @param {import('./User')|null} user
* @param {string[]} [include]
* @returns {Promise<oldCollection>} oldCollection.toJSONExpanded
* @async
*/
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')]
})) || []
this.books = await this.getBooksExpandedWithLibraryItem()
// 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
}
if (user) {
const books = this.books.filter((b) => {
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 && this.books.length) {
return null
}
// Users with restricted permissions will not see this collection
if (!books.length && this.books.length) {
return null
this.books = books
}
const collectionExpanded = this.toOldJSONExpanded(libraryItems)
const collectionExpanded = this.toOldJSONExpanded()
if (include?.includes('rssfeed')) {
const feeds = await this.getFeeds()
if (feeds?.length) {
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
collectionExpanded.rssFeed = feeds[0].toOldJSON()
}
}
@ -291,10 +257,10 @@ class Collection extends Model {
/**
*
* @param {string[]} libraryItemIds
* @param {string[]} [libraryItemIds=[]]
* @returns
*/
toOldJSON(libraryItemIds) {
toOldJSON(libraryItemIds = []) {
return {
id: this.id,
libraryId: this.libraryId,
@ -306,19 +272,19 @@ class Collection extends Model {
}
}
/**
*
* @param {import('../objects/LibraryItem')} oldLibraryItems
* @returns
*/
toOldJSONExpanded(oldLibraryItems) {
const json = this.toOldJSON(oldLibraryItems.map((li) => li.id))
json.books = json.books
.map((libraryItemId) => {
const book = oldLibraryItems.find((li) => li.id === libraryItemId)
return book ? book.toJSONExpanded() : null
})
.filter((b) => !!b)
toOldJSONExpanded() {
if (!this.books) {
throw new Error('Books are required to expand Collection')
}
const json = this.toOldJSON()
json.books = this.books.map((book) => {
const libraryItem = book.libraryItem
delete book.libraryItem
libraryItem.media = book
return libraryItem.toOldJSONExpanded()
})
return json
}
}

View file

@ -16,15 +16,6 @@ class CollectionBook extends Model {
this.createdAt
}
static removeByIds(collectionId, bookId) {
return this.destroy({
where: {
bookId,
collectionId
}
})
}
static init(sequelize) {
super.init(
{

View file

@ -1,6 +1,22 @@
const Path = require('path')
const { DataTypes, Model } = require('sequelize')
const oldFeed = require('../objects/Feed')
const areEquivalent = require('../utils/areEquivalent')
const Logger = require('../Logger')
const RSS = require('../libs/rss')
/**
* @typedef FeedOptions
* @property {boolean} preventIndexing
* @property {string} ownerName
* @property {string} ownerEmail
*/
/**
* @typedef FeedExpandedProperties
* @property {import('./FeedEpisode')} feedEpisodes
*
* @typedef {Feed & FeedExpandedProperties} FeedExpanded
*/
class Feed extends Model {
constructor(values, options) {
@ -50,210 +66,293 @@ class Feed extends Model {
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))
// Expanded properties
/** @type {import('./FeedEpisode')[]} - only set if expanded */
this.feedEpisodes
}
/**
* Get old feed from Feed and optionally Feed with FeedEpisodes
* @param {Feed} feedExpanded
* @returns {oldFeed}
* @param {string} feedId
* @returns {Promise<boolean>} - true if feed was removed
*/
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,
feedUrl: feedExpanded.feedURL,
episodes: episodes || [],
createdAt: feedExpanded.createdAt.valueOf(),
updatedAt: feedExpanded.updatedAt.valueOf()
})
}
static removeById(feedId) {
return this.destroy({
where: {
id: feedId
}
})
static async removeById(feedId) {
return (
(await this.destroy({
where: {
id: feedId
}
})) > 0
)
}
/**
* Find all library item ids that have an open feed (used in library filter)
* @returns {Promise<string[]>} array of library item ids
*
* @param {string} userId
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {Feed}
*/
static async findAllLibraryItemIds() {
const feeds = await this.findAll({
attributes: ['entityId'],
where: {
entityType: 'libraryItem'
}
})
return feeds.map((f) => f.entityId).filter((f) => f) || []
}
static getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions = null) {
const media = libraryItem.media
/**
* Find feed where and return oldFeed
* @param {Object} where sequelize where object
* @returns {Promise<oldFeed>} 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)
}
let entityUpdatedAt = libraryItem.updatedAt
/**
* Find feed and return oldFeed
* @param {string} id
* @returns {Promise<oldFeed>} 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)
}
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 = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
feedEpisode.feedId = newFeed.id
await this.sequelize.models.feedEpisode.create(feedEpisode)
}
// Podcast feeds should use the most recent episode updatedAt if more recent
if (libraryItem.mediaType === 'podcast') {
entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {
return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent
}, entityUpdatedAt)
} else if (libraryItem.media.updatedAt > entityUpdatedAt) {
// Book feeds will use Book.updatedAt if more recent
entityUpdatedAt = libraryItem.media.updatedAt
}
const feedObj = {
slug,
entityType: 'libraryItem',
entityId: libraryItem.id,
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,
siteURL: `/item/${libraryItem.id}`,
title: media.title,
description: media.description,
author: libraryItem.mediaType === 'podcast' ? media.author : media.authorName,
podcastType: libraryItem.mediaType === 'podcast' ? media.podcastType : 'serial',
language: media.language,
explicit: media.explicit,
coverPath: media.coverPath,
userId
}
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
return feedObj
}
static async fullUpdateFromOld(oldFeed) {
const oldFeedEpisodes = oldFeed.episodes || []
const feedObj = this.getFromOld(oldFeed)
/**
*
* @param {string} userId
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) {
const feedObj = this.getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
const existingFeed = await this.findByPk(feedObj.id, {
include: this.sequelize.models.feedEpisode
})
if (!existingFeed) return false
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
let hasUpdates = false
const transaction = await this.sequelize.transaction()
try {
const feed = await this.create(feedObj, { transaction })
// Remove and update existing feed episodes
for (const feedEpisode of existingFeed.feedEpisodes) {
const oldFeedEpisode = oldFeedEpisodes.find((ep) => ep.id === feedEpisode.id)
// Episode removed
if (!oldFeedEpisode) {
feedEpisode.destroy()
if (libraryItem.mediaType === 'podcast') {
feed.feedEpisodes = await feedEpisodeModel.createFromPodcastEpisodes(libraryItem, feed, slug, transaction)
} 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
}
feed.feedEpisodes = await feedEpisodeModel.createFromAudiobookTracks(libraryItem, feed, slug, transaction)
}
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for library item ${libraryItem.id}`, error)
await transaction.rollback()
return null
}
// Add new feed episodes
for (const episode of oldFeedEpisodes) {
if (!existingFeed.feedEpisodes.some((fe) => fe.id === episode.id)) {
await this.sequelize.models.feedEpisode.createFromOld(feedObj.id, episode)
hasUpdates = true
}
}
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 || {}
/**
*
* @param {string} userId
* @param {import('./Collection')} collectionExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
*/
static getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions = null) {
const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
return updatedAt > mostRecent ? updatedAt : mostRecent
}, collectionExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
return authorNames.concat(bookAuthorsToAdd)
}, [])
let author = allBookAuthorNames.slice(0, 3).join(', ')
if (allBookAuthorNames.length > 3) {
author += ' & more'
}
const feedObj = {
slug,
entityType: 'collection',
entityId: collectionExpanded.id,
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
siteURL: `/collection/${collectionExpanded.id}`,
title: collectionExpanded.name,
description: collectionExpanded.description || '',
author,
podcastType: 'serial',
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
coverPath: firstBookWithCover?.coverPath || null,
userId
}
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
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
feedObj,
booksWithTracks
}
}
getEntity(options) {
if (!this.entityType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
return this[mixinMethodName](options)
/**
*
* @param {string} userId
* @param {import('./Collection')} collectionExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions) {
const { feedObj, booksWithTracks } = this.getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
const transaction = await this.sequelize.transaction()
try {
const feed = await this.create(feedObj, { transaction })
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for collection ${collectionExpanded.id}`, error)
await transaction.rollback()
return null
}
}
/**
*
* @param {string} userId
* @param {import('./Series')} seriesExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
*/
static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {
const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
return updatedAt > mostRecent ? updatedAt : mostRecent
}, seriesExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
return authorNames.concat(bookAuthorsToAdd)
}, [])
let author = allBookAuthorNames.slice(0, 3).join(', ')
if (allBookAuthorNames.length > 3) {
author += ' & more'
}
const feedObj = {
slug,
entityType: 'series',
entityId: seriesExpanded.id,
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,
title: seriesExpanded.name,
description: seriesExpanded.description || '',
author,
podcastType: 'serial',
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
coverPath: firstBookWithCover?.coverPath || null,
userId
}
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
return {
feedObj,
booksWithTracks
}
}
/**
*
* @param {string} userId
* @param {import('./Series')} seriesExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions) {
const { feedObj, booksWithTracks } = this.getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
const transaction = await this.sequelize.transaction()
try {
const feed = await this.create(feedObj, { transaction })
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for series ${seriesExpanded.id}`, error)
await transaction.rollback()
return null
}
}
/**
@ -369,6 +468,199 @@ class Feed extends Model {
}
})
}
/**
*
* @returns {Promise<FeedExpanded>}
*/
async updateFeedForEntity() {
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
let feedObj = null
let feedEpisodeCreateFunc = null
let feedEpisodeCreateFuncEntity = null
if (this.entityType === 'libraryItem') {
/** @type {typeof import('./LibraryItem')} */
const libraryItemModel = this.sequelize.models.libraryItem
const itemExpanded = await libraryItemModel.getExpandedById(this.entityId)
feedObj = Feed.getFeedObjForLibraryItem(this.userId, itemExpanded, this.slug, this.serverAddress)
feedEpisodeCreateFuncEntity = itemExpanded
if (itemExpanded.mediaType === 'podcast') {
feedEpisodeCreateFunc = feedEpisodeModel.createFromPodcastEpisodes.bind(feedEpisodeModel)
} else {
feedEpisodeCreateFunc = feedEpisodeModel.createFromAudiobookTracks.bind(feedEpisodeModel)
}
} else if (this.entityType === 'collection') {
/** @type {typeof import('./Collection')} */
const collectionModel = this.sequelize.models.collection
const collectionExpanded = await collectionModel.getExpandedById(this.entityId)
const feedObjData = Feed.getFeedObjForCollection(this.userId, collectionExpanded, this.slug, this.serverAddress)
feedObj = feedObjData.feedObj
feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks
feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)
} else if (this.entityType === 'series') {
/** @type {typeof import('./Series')} */
const seriesModel = this.sequelize.models.series
const seriesExpanded = await seriesModel.getExpandedById(this.entityId)
const feedObjData = Feed.getFeedObjForSeries(this.userId, seriesExpanded, this.slug, this.serverAddress)
feedObj = feedObjData.feedObj
feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks
feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)
} else {
Logger.error(`[Feed] Invalid entity type ${this.entityType} for feed ${this.id}`)
return null
}
const transaction = await this.sequelize.transaction()
try {
const updatedFeed = await this.update(feedObj, { transaction })
const existingFeedEpisodeIds = this.feedEpisodes.map((ep) => ep.id)
// Create new feed episodes
updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction)
const newFeedEpisodeIds = updatedFeed.feedEpisodes.map((ep) => ep.id)
const feedEpisodeIdsToRemove = existingFeedEpisodeIds.filter((epid) => !newFeedEpisodeIds.includes(epid))
if (feedEpisodeIdsToRemove.length) {
Logger.info(`[Feed] Removing ${feedEpisodeIdsToRemove.length} episodes from feed ${this.id}`)
await feedEpisodeModel.destroy({
where: {
id: feedEpisodeIdsToRemove
},
transaction
})
}
await transaction.commit()
return updatedFeed
} catch (error) {
Logger.error(`[Feed] Error updating feed ${this.entityId}`, error)
await transaction.rollback()
return null
}
}
getEntity(options) {
if (!this.entityType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
return this[mixinMethodName](options)
}
/**
*
* @param {string} hostPrefix
*/
buildXml(hostPrefix) {
const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }]
const rssData = {
title: this.title,
description: this.description || '',
generator: 'Audiobookshelf',
feed_url: `${hostPrefix}${this.feedURL}`,
site_url: `${hostPrefix}${this.siteURL}`,
image_url: `${hostPrefix}${this.imageURL}`,
custom_namespaces: {
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
psc: 'http://podlove.org/simple-chapters',
podcast: 'https://podcastindex.org/namespace/1.0',
googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'
},
custom_elements: [
{ language: this.language || 'en' },
{ author: this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:summary': this.description || '' },
{ 'itunes:type': this.podcastType },
{
'itunes:image': {
_attr: {
href: `${hostPrefix}${this.imageURL}`
}
}
},
{
'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }]
},
{ 'itunes:explicit': !!this.explicit },
...(this.preventIndexing ? blockTags : [])
]
}
const rssfeed = new RSS(rssData)
this.feedEpisodes.forEach((ep) => {
rssfeed.item(ep.getRSSData(hostPrefix))
})
return rssfeed.xml()
}
/**
*
* @param {string} id
* @returns {string}
*/
getEpisodePath(id) {
const episode = this.feedEpisodes.find((ep) => ep.id === id)
if (!episode) return null
return episode.filePath
}
toOldJSON() {
const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
return {
id: this.id,
slug: this.slug,
userId: this.userId,
entityType: this.entityType,
entityId: this.entityId,
entityUpdatedAt: this.entityUpdatedAt?.valueOf() || null,
coverPath: this.coverPath || null,
meta: {
title: this.title,
description: this.description,
author: this.author,
imageUrl: this.imageURL,
feedUrl: this.feedURL,
link: this.siteURL,
explicit: this.explicit,
type: this.podcastType,
language: this.language,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
},
serverAddress: this.serverAddress,
feedUrl: this.feedURL,
episodes: episodes || [],
createdAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
}
}
toOldJSONMinified() {
return {
id: this.id,
entityType: this.entityType,
entityId: this.entityId,
feedUrl: this.feedURL,
meta: {
title: this.title,
description: this.description,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
}
}
module.exports = Feed

View file

@ -1,4 +1,9 @@
const Path = require('path')
const { DataTypes, Model } = require('sequelize')
const uuidv4 = require('uuid').v4
const Logger = require('../Logger')
const date = require('../libs/dateAndTime')
const { secondsToTimestamp } = require('../utils')
class FeedEpisode extends Model {
constructor(values, options) {
@ -9,6 +14,8 @@ class FeedEpisode extends Model {
/** @type {string} */
this.title
/** @type {string} */
this.author
/** @type {string} */
this.description
/** @type {string} */
this.siteURL
@ -40,60 +47,192 @@ class FeedEpisode extends Model {
this.updatedAt
}
getOldEpisode() {
const enclosure = {
url: this.enclosureURL,
size: this.enclosureSize,
type: this.enclosureType
}
/**
*
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
* @param {import('./Feed')} feed
* @param {string} slug
* @param {import('./PodcastEpisode')} episode
* @param {string} [existingEpisodeId]
*/
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisodeId = null) {
const episodeId = existingEpisodeId || uuidv4()
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
id: episodeId,
title: episode.title,
author: feed.author,
description: episode.description,
siteURL: feed.siteURL,
enclosureURL: `/feed/${slug}/item/${episodeId}/media${Path.extname(episode.audioFile.metadata.filename)}`,
enclosureType: episode.audioFile.mimeType,
enclosureSize: episode.audioFile.metadata.size,
pubDate: episode.pubDate,
season: episode.season,
episode: episode.episode,
episodeType: episode.episodeType,
duration: episode.audioFile.duration,
filePath: episode.audioFile.metadata.path,
explicit: libraryItemExpanded.media.explicit,
feedId: feed.id
}
}
/**
* Create feed episode from old model
*
* @param {string} feedId
* @param {Object} oldFeedEpisode
* @returns {Promise<FeedEpisode>}
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
* @param {import('./Feed')} feed
* @param {string} slug
* @param {import('sequelize').Transaction} transaction
* @returns {Promise<FeedEpisode[]>}
*/
static createFromOld(feedId, oldFeedEpisode) {
const newEpisode = this.getFromOld(oldFeedEpisode)
newEpisode.feedId = feedId
return this.create(newEpisode)
static async createFromPodcastEpisodes(libraryItemExpanded, feed, slug, transaction) {
const feedEpisodeObjs = []
// Sort podcastEpisodes by pubDate. episodic is newest to oldest. serial is oldest to newest.
if (feed.podcastType === 'episodic') {
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
} else {
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))
}
let numExisting = 0
for (const episode of libraryItemExpanded.media.podcastEpisodes) {
// Check for existing episode by filepath
const existingEpisode = feed.feedEpisodes?.find((feedEpisode) => {
return feedEpisode.filePath === episode.audioFile.metadata.path
})
numExisting = existingEpisode ? numExisting + 1 : numExisting
feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisode?.id))
}
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
}
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
/**
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
*
* @param {import('./Book').AudioTrack[]} trackList
* @param {import('./Book')} book
* @returns {boolean}
*/
static checkUseChapterTitlesForEpisodes(trackList, book) {
const chapters = book.chapters || []
if (trackList.length !== chapters.length) return false
for (let i = 0; i < trackList.length; i++) {
if (Math.abs(chapters[i].start - trackList[i].startOffset) >= 1) {
return false
}
}
return true
}
/**
*
* @param {import('./Book')} book
* @param {Date} pubDateStart
* @param {import('./Feed')} feed
* @param {string} slug
* @param {import('./Book').AudioFileObject} audioTrack
* @param {boolean} useChapterTitles
* @param {string} [existingEpisodeId]
*/
static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles, existingEpisodeId = null) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
let episodeId = existingEpisodeId || uuidv4()
// e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
let title = Path.basename(audioTrack.metadata.filename, Path.extname(audioTrack.metadata.filename))
if (book.includedAudioFiles.length == 1) {
// If audiobook is a single file, use book title instead of chapter/file title
title = book.title
} else {
if (useChapterTitles) {
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
const matchingChapter = book.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
if (matchingChapter?.title) title = matchingChapter.title
}
}
return {
id: episodeId,
title,
author: feed.author,
description: book.description || '',
siteURL: feed.siteURL,
enclosureURL: contentUrl,
enclosureType: audioTrack.mimeType,
enclosureSize: audioTrack.metadata.size,
pubDate: audiobookPubDate,
duration: audioTrack.duration,
filePath: audioTrack.metadata.path,
explicit: book.explicit,
feedId: feed.id
}
}
/**
*
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
* @param {import('./Feed')} feed
* @param {string} slug
* @param {import('sequelize').Transaction} transaction
* @returns {Promise<FeedEpisode[]>}
*/
static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) {
const trackList = libraryItemExpanded.getTrackList()
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, libraryItemExpanded.media)
const feedEpisodeObjs = []
let numExisting = 0
for (const track of trackList) {
// Check for existing episode by filepath
const existingEpisode = feed.feedEpisodes?.find((episode) => {
return episode.filePath === track.metadata.path
})
numExisting = existingEpisode ? numExisting + 1 : numExisting
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles, existingEpisode?.id))
}
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
}
/**
*
* @param {import('./Book').BookExpandedWithLibraryItem[]} books
* @param {import('./Feed')} feed
* @param {string} slug
* @param {import('sequelize').Transaction} transaction
* @returns {Promise<FeedEpisode[]>}
*/
static async createFromBooks(books, feed, slug, transaction) {
const earliestLibraryItemCreatedAt = books.reduce((earliest, book) => {
return book.libraryItem.createdAt < earliest.libraryItem.createdAt ? book : earliest
}).libraryItem.createdAt
const feedEpisodeObjs = []
let numExisting = 0
for (const book of books) {
const trackList = book.libraryItem.getTrackList()
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, book)
for (const track of trackList) {
// Check for existing episode by filepath
const existingEpisode = feed.feedEpisodes?.find((episode) => {
return episode.filePath === track.metadata.path
})
numExisting = existingEpisode ? numExisting + 1 : numExisting
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles, existingEpisode?.id))
}
}
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
}
/**
@ -136,6 +275,60 @@ class FeedEpisode extends Model {
})
FeedEpisode.belongsTo(feed)
}
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
}
}
/**
*
* @param {string} hostPrefix
*/
getRSSData(hostPrefix) {
return {
title: this.title,
description: this.description || '',
url: `${hostPrefix}${this.siteURL}`,
guid: `${hostPrefix}${this.enclosureURL}`,
author: this.author,
date: this.pubDate,
enclosure: {
url: `${hostPrefix}${this.enclosureURL}`,
type: this.enclosureType,
size: this.enclosureSize
},
custom_elements: [
{ 'itunes:author': this.author },
{ 'itunes:duration': secondsToTimestamp(this.duration) },
{ 'itunes:summary': this.description || '' },
{
'itunes:explicit': !!this.explicit
},
{ 'itunes:episodeType': this.episodeType },
{ 'itunes:season': this.season },
{ 'itunes:episode': this.episode }
]
}
}
}
module.exports = FeedEpisode

View file

@ -1,11 +1,8 @@
const util = require('util')
const Path = require('path')
const { DataTypes, Model } = require('sequelize')
const fsExtra = require('../libs/fsExtra')
const Logger = require('../Logger')
const oldLibraryItem = require('../objects/LibraryItem')
const libraryFilters = require('../utils/queries/libraryFilters')
const { areEquivalent } = require('../utils/index')
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const LibraryFile = require('../objects/files/LibraryFile')
const Book = require('./Book')
@ -73,6 +70,9 @@ class LibraryItem extends Model {
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */
this.media
}
/**
@ -120,12 +120,27 @@ class LibraryItem extends Model {
}
/**
* Currently unused because this is too slow and uses too much mem
* @param {import('sequelize').WhereOptions} [where]
* @returns {Array<objects.LibraryItem>} old library items
* Remove library item by id
*
* @param {string} libraryItemId
* @returns {Promise<number>} The number of destroyed rows
*/
static async getAllOldLibraryItems(where = null) {
let libraryItems = await this.findAll({
static removeById(libraryItemId) {
return this.destroy({
where: {
id: libraryItemId
},
individualHooks: true
})
}
/**
*
* @param {import('sequelize').WhereOptions} where
* @returns {Promise<LibraryItemExpanded[]>}
*/
static async findAllExpandedWhere(where = null) {
return this.findAll({
where,
include: [
{
@ -147,302 +162,17 @@ class LibraryItem extends Model {
},
{
model: this.sequelize.models.podcast,
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
include: {
model: this.sequelize.models.podcastEpisode
}
}
],
order: [
// 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']
]
})
return libraryItems.map((ti) => this.getOldLibraryItem(ti))
}
/**
* Convert an expanded LibraryItem into an old library item
*
* @param {Model<LibraryItem>} 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)
}
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
})
}
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)
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)
}
}
return newLibraryItem
}
/**
* Updates libraryItem, book, authors and series from old library item
*
* @param {oldLibraryItem} oldLibraryItem
* @returns {Promise<boolean>} true if updates were made
*/
static async fullUpdateFromOld(oldLibraryItem) {
const libraryItemExpanded = await this.getExpandedById(oldLibraryItem.id)
if (!libraryItemExpanded) return false
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.debug(`[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.debug(`[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.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from %j to %j`, existingValue, 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 uniqueUpdatedAuthors = updatedAuthors.filter((au, idx) => updatedAuthors.findIndex((a) => a.id === au.id) === idx)
const updatedSeriesAll = oldLibraryItem.media.metadata.series || []
for (const existingAuthor of existingAuthors) {
// Author was removed from Book
if (!uniqueUpdatedAuthors.some((au) => au.id === existingAuthor.id)) {
Logger.debug(`[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 uniqueUpdatedAuthors) {
// Author was added
if (!existingAuthors.some((au) => au.id === updatedAuthor.id)) {
Logger.debug(`[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.debug(`[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.debug(`[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.debug(`[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)) {
if (key === 'chapters') {
// Handle logging of chapters separately because the object is large
const chaptersRemoved = libraryItemExpanded.media.chapters.filter((ch) => !updatedMedia.chapters.some((uch) => uch.id === ch.id))
if (chaptersRemoved.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters removed: ${chaptersRemoved.map((ch) => ch.title).join(', ')}`)
}
const chaptersAdded = updatedMedia.chapters.filter((uch) => !libraryItemExpanded.media.chapters.some((ch) => ch.id === uch.id))
if (chaptersAdded.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters added: ${chaptersAdded.map((ch) => ch.title).join(', ')}`)
}
if (!chaptersRemoved.length && !chaptersAdded.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters updated`)
}
} else {
Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from %j to %j`, existingValue, 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]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) {
if (key === 'libraryFiles') {
// Handle logging of libraryFiles separately because the object is large (should be addressed when migrating off the old library item model)
const libraryFilesRemoved = libraryItemExpanded.libraryFiles.filter((lf) => !updatedLibraryItem.libraryFiles.some((ulf) => ulf.ino === lf.ino))
if (libraryFilesRemoved.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files removed: ${libraryFilesRemoved.map((lf) => lf.metadata.path).join(', ')}`)
}
const libraryFilesAdded = updatedLibraryItem.libraryFiles.filter((ulf) => !libraryItemExpanded.libraryFiles.some((lf) => lf.ino === ulf.ino))
if (libraryFilesAdded.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files added: ${libraryFilesAdded.map((lf) => lf.metadata.path).join(', ')}`)
}
if (!libraryFilesRemoved.length && !libraryFilesAdded.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files updated`)
}
} else {
Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from %j to %j`, existingValue, updatedLibraryItem[key]))
}
hasLibraryItemUpdates = true
if (key === 'updatedAt') {
libraryItemExpanded.changed('updatedAt', true)
}
}
}
if (hasLibraryItemUpdates) {
await libraryItemExpanded.update(updatedLibraryItem)
Logger.info(`[LibraryItem] Library item "${libraryItemExpanded.id}" updated`)
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,
updatedAt: oldLibraryItem.updatedAt,
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
}
}
/**
* Remove library item by id
*
* @param {string} libraryItemId
* @returns {Promise<number>} The number of destroyed rows
*/
static removeById(libraryItemId) {
return this.destroy({
where: {
id: libraryItemId
},
individualHooks: true
})
}
/**
@ -495,16 +225,20 @@ class LibraryItem extends Model {
}
/**
* Get old library item by id
* @param {string} libraryItemId
* @returns {oldLibraryItem}
*
* @param {import('sequelize').WhereOptions} where
* @param {import('sequelize').BindOrReplacements} [replacements]
* @param {import('sequelize').IncludeOptions} [include]
* @returns {Promise<LibraryItemExpanded>}
*/
static async getOldById(libraryItemId) {
if (!libraryItemId) return null
const libraryItem = await this.findByPk(libraryItemId)
static async findOneExpanded(where, replacements = null, include = null) {
const libraryItem = await this.findOne({
where,
replacements,
include
})
if (!libraryItem) {
Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
Logger.error(`[LibraryItem] Library item not found`)
return null
}
@ -528,7 +262,7 @@ class LibraryItem extends Model {
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
attributes: ['id', 'sequence']
}
}
],
@ -540,7 +274,7 @@ class LibraryItem extends Model {
}
if (!libraryItem.media) return null
return this.getOldLibraryItem(libraryItem)
return libraryItem
}
/**
@ -548,7 +282,7 @@ class LibraryItem extends Model {
* @param {import('./Library')} library
* @param {import('./User')} user
* @param {object} options
* @returns {{ libraryItems:oldLibraryItem[], count:number }}
* @returns {{ libraryItems:Object[], count:number }}
*/
static async getByFilterAndSort(library, user, options) {
let start = Date.now()
@ -557,7 +291,7 @@ class LibraryItem extends Model {
return {
libraryItems: libraryItems.map((li) => {
const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = li.toOldJSONMinified()
if (li.collapsedSeries) {
oldLibraryItem.collapsedSeries = li.collapsedSeries
}
@ -565,7 +299,7 @@ class LibraryItem extends Model {
oldLibraryItem.media.metadata.series = li.series
}
if (li.rssFeed) {
oldLibraryItem.rssFeed = this.sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.media.numEpisodes) {
oldLibraryItem.media.numEpisodes = li.media.numEpisodes
@ -602,17 +336,19 @@ class LibraryItem extends Model {
// "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)
const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
const audioItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
shelves.push({
id: 'continue-listening',
label: 'Continue Listening',
labelStringKey: 'LabelContinueListening',
type: library.isPodcast ? 'episode' : 'book',
entities: audioOnlyItemsInProgress,
total: itemsInProgressPayload.count
})
if (audioItemsInProgress.length) {
shelves.push({
id: 'continue-listening',
label: 'Continue Listening',
labelStringKey: 'LabelContinueListening',
type: library.isPodcast ? 'episode' : 'book',
entities: audioItemsInProgress,
total: itemsInProgressPayload.count
})
}
if (ebookOnlyItemsInProgress.length) {
// "Continue Reading" shelf
@ -711,17 +447,19 @@ class LibraryItem extends Model {
// "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)
const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
shelves.push({
id: 'listen-again',
label: 'Listen Again',
labelStringKey: 'LabelListenAgain',
type: library.isPodcast ? 'episode' : 'book',
entities: audioOnlyItemsInProgress,
total: mediaFinishedPayload.count
})
if (audioItemsInProgress.length) {
shelves.push({
id: 'listen-again',
label: 'Listen Again',
labelStringKey: 'LabelListenAgain',
type: library.isPodcast ? 'episode' : 'book',
entities: audioItemsInProgress,
total: mediaFinishedPayload.count
})
}
// "Read Again" shelf
if (ebookOnlyItemsInProgress.length) {
@ -763,21 +501,11 @@ class LibraryItem extends Model {
* Get book library items for author, optional use user permissions
* @param {import('./Author')} author
* @param {import('./User')} user
* @returns {Promise<oldLibraryItem[]>}
* @returns {Promise<LibraryItemExpanded[]>}
*/
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<oldLibraryItem[]>}
*/
static async getForCollection(collection) {
const libraryItems = await libraryFilters.getLibraryItemsForCollection(collection)
return libraryItems.map((li) => this.getOldLibraryItem(li))
return libraryItems
}
/**
@ -789,52 +517,6 @@ class LibraryItem extends Model {
return (await this.count({ where: { id: libraryItemId } })) > 0
}
/**
*
* @param {import('sequelize').WhereOptions} where
* @param {import('sequelize').BindOrReplacements} replacements
* @returns {Object} oldLibraryItem
*/
static async findOneOld(where, replacements = {}) {
const libraryItem = await this.findOne({
where,
replacements,
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)
}
/**
*
* @param {string} libraryItemId
@ -862,54 +544,6 @@ class LibraryItem extends Model {
return libraryItem.media.coverPath
}
/**
*
* @param {import('sequelize').FindOptions} options
* @returns {Promise<Book|Podcast>}
*/
getMedia(options) {
if (!this.mediaType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}`
return this[mixinMethodName](options)
}
/**
*
* @returns {Promise<Book|Podcast>}
*/
getMediaExpanded() {
if (this.mediaType === 'podcast') {
return this.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
return this.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
}
/**
*
* @returns {Promise}
@ -1006,7 +640,7 @@ class LibraryItem extends Model {
}
}
Logger.debug(`Success saving abmetadata to "${metadataFilePath}"`)
Logger.debug(`[LibraryItem] Saved metadata for "${this.media.title}" file to "${metadataFilePath}"`)
return metadataLibraryFile
})
@ -1058,6 +692,9 @@ class LibraryItem extends Model {
{
fields: ['libraryId', 'mediaType']
},
{
fields: ['libraryId', 'mediaType', 'size']
},
{
fields: ['libraryId', 'mediaId', 'mediaType']
},
@ -1124,6 +761,219 @@ class LibraryItem extends Model {
}
})
}
get isBook() {
return this.mediaType === 'book'
}
get isPodcast() {
return this.mediaType === 'podcast'
}
get hasAudioTracks() {
return this.media.hasAudioTracks()
}
/**
*
* @param {import('sequelize').FindOptions} options
* @returns {Promise<Book|Podcast>}
*/
getMedia(options) {
if (!this.mediaType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}`
return this[mixinMethodName](options)
}
/**
*
* @returns {Promise<Book|Podcast>}
*/
getMediaExpanded() {
if (this.mediaType === 'podcast') {
return this.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
return this.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
}
/**
* Check if book or podcast library item has audio tracks
* Requires expanded library item
*
* @returns {boolean}
*/
hasAudioTracks() {
if (!this.media) {
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
return false
}
if (this.isBook) {
return this.media.audioFiles?.length > 0
} else {
return this.media.podcastEpisodes?.length > 0
}
}
/**
*
* @param {string} ino
* @returns {import('./Book').AudioFileObject}
*/
getAudioFileWithIno(ino) {
if (!this.media) {
Logger.error(`[LibraryItem] getAudioFileWithIno: Library item "${this.id}" does not have media`)
return null
}
if (this.isBook) {
return this.media.audioFiles.find((af) => af.ino === ino)
} else {
return this.media.podcastEpisodes.find((pe) => pe.audioFile?.ino === ino)?.audioFile
}
}
/**
* Get the track list to be used in client audio players
* AudioTrack is the AudioFile with startOffset and contentUrl
* Podcasts must have an episodeId to get the track list
*
* @param {string} [episodeId]
* @returns {import('./Book').AudioTrack[]}
*/
getTrackList(episodeId) {
if (!this.media) {
Logger.error(`[LibraryItem] getTrackList: Library item "${this.id}" does not have media`)
return []
}
return this.media.getTracklist(this.id, episodeId)
}
/**
*
* @param {string} ino
* @returns {LibraryFile}
*/
getLibraryFileWithIno(ino) {
const libraryFile = this.libraryFiles.find((lf) => lf.ino === ino)
if (!libraryFile) return null
return new LibraryFile(libraryFile)
}
getLibraryFiles() {
return this.libraryFiles.map((lf) => new LibraryFile(lf))
}
getLibraryFilesJson() {
return this.libraryFiles.map((lf) => new LibraryFile(lf).toJSON())
}
toOldJSON() {
if (!this.media) {
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
}
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
lastScan: this.lastScan?.valueOf(),
scanVersion: this.lastScanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSON(this.id),
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
libraryFiles: this.getLibraryFilesJson()
}
}
toOldJSONMinified() {
if (!this.media) {
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
}
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSONMinified(),
numFiles: this.libraryFiles.length,
size: this.size
}
}
toOldJSONExpanded() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
lastScan: this.lastScan?.valueOf(),
scanVersion: this.lastScanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSONExpanded(this.id),
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
libraryFiles: this.getLibraryFilesJson(),
size: this.size
}
}
}
module.exports = LibraryItem

View file

@ -12,6 +12,7 @@ const { DataTypes, Model } = require('sequelize')
* @property {Object} extraData
* @property {Date} createdAt
* @property {Date} updatedAt
* @property {boolean} isDownloadable
*
* @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel
*/
@ -25,11 +26,40 @@ const { DataTypes, Model } = require('sequelize')
* @property {Date} expiresAt
* @property {Date} createdAt
* @property {Date} updatedAt
* @property {boolean} isDownloadable
*/
class MediaItemShare extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {UUIDV4} */
this.mediaItemId
/** @type {string} */
this.mediaItemType
/** @type {string} */
this.slug
/** @type {string} */
this.pash
/** @type {UUIDV4} */
this.userId
/** @type {Date} */
this.expiresAt
/** @type {Object} */
this.extraData
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {boolean} */
this.isDownloadable
// Expanded properties
/** @type {import('./Book')|import('./PodcastEpisode')} */
this.mediaItem
}
toJSONForClient() {
@ -40,47 +70,29 @@ class MediaItemShare extends Model {
slug: this.slug,
expiresAt: this.expiresAt,
createdAt: this.createdAt,
updatedAt: this.updatedAt
updatedAt: this.updatedAt,
isDownloadable: this.isDownloadable
}
}
/**
* Expanded book that includes library settings
*
* @param {string} mediaItemId
* @param {string} mediaItemType
* @returns {Promise<import('../objects/LibraryItem')>}
* @returns {Promise<import('./LibraryItem').LibraryItemExpanded>}
*/
static async getMediaItemsOldLibraryItem(mediaItemId, mediaItemType) {
static async getMediaItemsLibraryItem(mediaItemId, mediaItemType) {
/** @type {typeof import('./LibraryItem')} */
const libraryItemModel = this.sequelize.models.libraryItem
if (mediaItemType === 'book') {
const book = await this.sequelize.models.book.findByPk(mediaItemId, {
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
},
{
model: this.sequelize.models.libraryItem,
include: {
model: this.sequelize.models.library,
attributes: ['settings']
}
}
]
const libraryItem = await libraryItemModel.findOneExpanded({ mediaId: mediaItemId }, null, {
model: this.sequelize.models.library,
attributes: ['settings']
})
const libraryItem = book.libraryItem
libraryItem.media = book
delete book.libraryItem
const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
oldLibraryItem.librarySettings = libraryItem.library.settings
return oldLibraryItem
return libraryItem
}
return null
}
@ -114,7 +126,8 @@ class MediaItemShare extends Model {
slug: DataTypes.STRING,
pash: DataTypes.STRING,
expiresAt: DataTypes.DATE,
extraData: DataTypes.JSON
extraData: DataTypes.JSON,
isDownloadable: DataTypes.BOOLEAN
},
{
sequelize,

View file

@ -36,33 +36,6 @@ class MediaProgress extends Model {
this.createdAt
}
static upsertFromOld(oldMediaProgress) {
const mediaProgress = this.getFromOld(oldMediaProgress)
return this.upsert(mediaProgress)
}
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: {
@ -71,12 +44,6 @@ class MediaProgress extends Model {
})
}
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
}
/**
* Initialize model
*
@ -162,6 +129,12 @@ class MediaProgress extends Model {
MediaProgress.belongsTo(user)
}
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
}
getOldMediaProgress() {
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'

View file

@ -1,7 +1,6 @@
const { DataTypes, Model, Op, literal } = require('sequelize')
const { DataTypes, Model, Op } = require('sequelize')
const Logger = require('../Logger')
const oldPlaylist = require('../objects/Playlist')
const SocketAuthority = require('../SocketAuthority')
class Playlist extends Model {
constructor(values, options) {
@ -21,141 +20,23 @@ class Playlist extends Model {
this.createdAt
/** @type {Date} */
this.updatedAt
}
static getOldPlaylist(playlistExpanded) {
const items = playlistExpanded.playlistMediaItems
.map((pmi) => {
const mediaItem = pmi.mediaItem || pmi.dataValues?.mediaItem
const libraryItemId = mediaItem?.podcast?.libraryItem?.id || 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)
// Expanded properties
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()
})
/** @type {import('./PlaylistMediaItem')[]} - only set when expanded */
this.playlistMediaItems
}
/**
* Get old playlist toJSONExpanded
* @param {string[]} [include]
* @returns {Promise<oldPlaylist>} 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
}
}
static removeById(playlistId) {
return this.destroy({
where: {
id: playlistId
}
})
}
/**
* Get playlist by id
* @param {string} playlistId
* @returns {Promise<oldPlaylist|null>} 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 old playlists for user and optionally for library
* Get old playlists for user and library
*
* @param {string} userId
* @param {string} [libraryId]
* @returns {Promise<oldPlaylist[]>}
* @param {string} libraryId
* @async
*/
static async getOldPlaylistsForUserAndLibrary(userId, libraryId = null) {
static async getOldPlaylistsForUserAndLibrary(userId, libraryId) {
if (!userId && !libraryId) return []
const whereQuery = {}
if (userId) {
whereQuery.userId = userId
@ -170,7 +51,23 @@ class Playlist extends Model {
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
},
{
model: this.sequelize.models.podcastEpisode,
@ -181,42 +78,13 @@ class Playlist extends Model {
}
]
},
order: [
[literal('name COLLATE NOCASE'), 'ASC'],
['playlistMediaItems', 'order', 'ASC']
]
order: [['playlistMediaItems', 'order', 'ASC']]
})
const oldPlaylists = []
for (const playlistExpanded of playlistsExpanded) {
const oldPlaylist = this.getOldPlaylist(playlistExpanded)
const libraryItems = []
for (const pmi of playlistExpanded.playlistMediaItems) {
let mediaItem = pmi.mediaItem || pmi.dataValues.mediaItem
// Sort by name asc
playlistsExpanded.sort((a, b) => a.name.localeCompare(b.name))
if (!mediaItem) {
Logger.error(`[Playlist] Invalid playlist media item - No media item found`, JSON.stringify(mediaItem, null, 2))
continue
}
let libraryItem = mediaItem.libraryItem || mediaItem.podcast?.libraryItem
if (mediaItem.podcast) {
libraryItem.media = mediaItem.podcast
libraryItem.media.podcastEpisodes = [mediaItem]
delete mediaItem.podcast.libraryItem
} else {
libraryItem.media = mediaItem
delete mediaItem.libraryItem
}
const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
libraryItems.push(oldLibraryItem)
}
const oldPlaylistJson = oldPlaylist.toJSONExpanded(libraryItems)
oldPlaylists.push(oldPlaylistJson)
}
return oldPlaylists
return playlistsExpanded.map((playlist) => playlist.toOldJSONExpanded())
}
/**
@ -296,6 +164,49 @@ class Playlist extends Model {
return playlists
}
/**
* Removes media items and re-orders playlists
*
* @param {string[]} mediaItemIds
*/
static async removeMediaItemsFromPlaylists(mediaItemIds) {
if (!mediaItemIds?.length) return
const playlistsWithItem = await this.getPlaylistsForMediaItemIds(mediaItemIds)
if (!playlistsWithItem.length) return
for (const playlist of playlistsWithItem) {
let numMediaItems = playlist.playlistMediaItems.length
let order = 1
// Remove items in playlist and re-order
for (const playlistMediaItem of playlist.playlistMediaItems) {
if (mediaItemIds.includes(playlistMediaItem.mediaItemId)) {
await playlistMediaItem.destroy()
numMediaItems--
} else {
if (playlistMediaItem.order !== order) {
playlistMediaItem.update({
order
})
}
order++
}
}
// If playlist is now empty then remove it
const jsonExpanded = await playlist.getOldJsonExpanded()
if (!numMediaItems) {
Logger.info(`[ApiRouter] Playlist "${playlist.name}" has no more items - removing it`)
await playlist.destroy()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
} else {
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
}
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
@ -352,6 +263,117 @@ class Playlist extends Model {
}
})
}
/**
* Get all media items in playlist expanded with library item
*
* @returns {Promise<import('./PlaylistMediaItem')[]>}
*/
getMediaItemsExpandedWithLibraryItem() {
return this.getPlaylistMediaItems({
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']
}
}
]
},
{
model: this.sequelize.models.podcastEpisode,
include: [
{
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
]
}
],
order: [['order', 'ASC']]
})
}
/**
* Get playlists toOldJSONExpanded
*
* @async
*/
async getOldJsonExpanded() {
this.playlistMediaItems = await this.getMediaItemsExpandedWithLibraryItem()
return this.toOldJSONExpanded()
}
/**
* Old model used libraryItemId instead of bookId
*
* @param {string} libraryItemId
* @param {string} [episodeId]
*/
checkHasMediaItem(libraryItemId, episodeId) {
if (!this.playlistMediaItems) {
throw new Error('playlistMediaItems are required to check Playlist')
}
if (episodeId) {
return this.playlistMediaItems.some((pmi) => pmi.mediaItemId === episodeId)
}
return this.playlistMediaItems.some((pmi) => pmi.mediaItem.libraryItem.id === libraryItemId)
}
toOldJSON() {
return {
id: this.id,
name: this.name,
libraryId: this.libraryId,
userId: this.userId,
description: this.description,
lastUpdate: this.updatedAt.valueOf(),
createdAt: this.createdAt.valueOf()
}
}
toOldJSONExpanded() {
if (!this.playlistMediaItems) {
throw new Error('playlistMediaItems are required to expand Playlist')
}
const json = this.toOldJSON()
json.items = this.playlistMediaItems.map((pmi) => {
if (pmi.mediaItemType === 'book') {
const libraryItem = pmi.mediaItem.libraryItem
delete pmi.mediaItem.libraryItem
libraryItem.media = pmi.mediaItem
return {
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONExpanded()
}
}
const libraryItem = pmi.mediaItem.podcast.libraryItem
delete pmi.mediaItem.podcast.libraryItem
libraryItem.media = pmi.mediaItem.podcast
return {
episodeId: pmi.mediaItemId,
episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONMinified()
}
})
return json
}
}
module.exports = Playlist

View file

@ -16,15 +16,11 @@ class PlaylistMediaItem extends Model {
this.playlistId
/** @type {Date} */
this.createdAt
}
static removeByIds(playlistId, mediaItemId) {
return this.destroy({
where: {
playlistId,
mediaItemId
}
})
// Expanded properties
/** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */
this.mediaItem
}
getMediaItem(options) {

View file

@ -1,4 +1,6 @@
const { DataTypes, Model } = require('sequelize')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const Logger = require('../Logger')
/**
* @typedef PodcastExpandedProperties
@ -47,6 +49,8 @@ class Podcast extends Model {
this.lastEpisodeCheck
/** @type {number} */
this.maxEpisodesToKeep
/** @type {number} */
this.maxNewEpisodesToDownload
/** @type {string} */
this.coverPath
/** @type {string[]} */
@ -57,85 +61,48 @@ class Podcast extends Model {
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {import('./PodcastEpisode')[]} */
this.podcastEpisodes
}
static getOldPodcast(libraryItemExpanded) {
const podcastExpanded = libraryItemExpanded.media
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,
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
/**
* Payload from the /api/podcasts POST endpoint
*
* @param {Object} payload
* @param {import('sequelize').Transaction} transaction
*/
static async createFromRequest(payload, transaction) {
const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null
const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null
const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : []
const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : []
return this.create(
{
title,
titleIgnorePrefix: getTitleIgnorePrefix(title),
author: typeof payload.metadata.author === 'string' ? payload.metadata.author : null,
releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,
feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,
imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null,
description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null,
itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null,
podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null,
explicit: !!payload.metadata.explicit,
autoDownloadEpisodes: !!payload.autoDownloadEpisodes,
autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule,
lastEpisodeCheck: new Date(),
maxEpisodesToKeep: 0,
maxNewEpisodesToDownload: 3,
tags,
genres
},
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
}
}
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
}
}
getAbsMetadataJson() {
return {
tags: this.tags || [],
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: this.genres || [],
feedURL: this.feedURL,
imageURL: this.imageURL,
itunesPageURL: this.itunesPageURL,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
language: this.language,
explicit: !!this.explicit,
podcastType: this.podcastType
}
{ transaction }
)
}
/**
@ -179,6 +146,311 @@ class Podcast extends Model {
}
)
}
get hasMediaFiles() {
return !!this.podcastEpisodes?.length
}
get hasAudioTracks() {
return this.hasMediaFiles
}
get size() {
if (!this.podcastEpisodes?.length) return 0
return this.podcastEpisodes.reduce((total, episode) => total + episode.size, 0)
}
getAbsMetadataJson() {
return {
tags: this.tags || [],
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: this.genres || [],
feedURL: this.feedURL,
imageURL: this.imageURL,
itunesPageURL: this.itunesPageURL,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
language: this.language,
explicit: !!this.explicit,
podcastType: this.podcastType
}
}
/**
*
* @param {Object} payload - Old podcast object
* @returns {Promise<boolean>}
*/
async updateFromRequest(payload) {
if (!payload) return false
let hasUpdates = false
if (payload.metadata) {
const stringKeys = ['title', 'author', 'releaseDate', 'feedUrl', 'imageUrl', 'description', 'itunesPageUrl', 'itunesId', 'itunesArtistId', 'language', 'type']
stringKeys.forEach((key) => {
let newKey = key
if (key === 'type') {
newKey = 'podcastType'
} else if (key === 'feedUrl') {
newKey = 'feedURL'
} else if (key === 'imageUrl') {
newKey = 'imageURL'
} else if (key === 'itunesPageUrl') {
newKey = 'itunesPageURL'
}
if (typeof payload.metadata[key] === 'string' && payload.metadata[key] !== this[newKey]) {
this[newKey] = payload.metadata[key]
if (key === 'title') {
this.titleIgnorePrefix = getTitleIgnorePrefix(this.title)
}
hasUpdates = true
}
})
if (payload.metadata.explicit !== undefined && payload.metadata.explicit !== this.explicit) {
this.explicit = !!payload.metadata.explicit
hasUpdates = true
}
if (Array.isArray(payload.metadata.genres) && !payload.metadata.genres.some((item) => typeof item !== 'string') && JSON.stringify(this.genres) !== JSON.stringify(payload.metadata.genres)) {
this.genres = payload.metadata.genres
this.changed('genres', true)
hasUpdates = true
}
}
if (Array.isArray(payload.tags) && !payload.tags.some((item) => typeof item !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) {
this.tags = payload.tags
this.changed('tags', true)
hasUpdates = true
}
if (payload.autoDownloadEpisodes !== undefined && payload.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
this.autoDownloadEpisodes = !!payload.autoDownloadEpisodes
hasUpdates = true
}
if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) {
this.autoDownloadSchedule = payload.autoDownloadSchedule
hasUpdates = true
}
if (typeof payload.lastEpisodeCheck === 'number' && payload.lastEpisodeCheck !== this.lastEpisodeCheck?.valueOf()) {
this.lastEpisodeCheck = payload.lastEpisodeCheck
hasUpdates = true
}
const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload']
numberKeys.forEach((key) => {
if (typeof payload[key] === 'number' && payload[key] !== this[key]) {
this[key] = payload[key]
hasUpdates = true
}
})
if (hasUpdates) {
Logger.debug(`[Podcast] changed keys:`, this.changed())
await this.save()
}
return hasUpdates
}
checkCanDirectPlay(supportedMimeTypes, episodeId) {
if (!Array.isArray(supportedMimeTypes)) {
Logger.error(`[Podcast] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes)
return false
}
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[Podcast] checkCanDirectPlay: episode not found`, episodeId)
return false
}
return supportedMimeTypes.includes(episode.audioFile.mimeType)
}
/**
* Get the track list to be used in client audio players
* AudioTrack is the AudioFile with startOffset and contentUrl
* Podcast episodes only have one track
*
* @param {string} libraryItemId
* @param {string} episodeId
* @returns {import('./Book').AudioTrack[]}
*/
getTracklist(libraryItemId, episodeId) {
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[Podcast] getTracklist: episode not found`, episodeId)
return []
}
const audioTrack = episode.getAudioTrack(libraryItemId)
return [audioTrack]
}
/**
*
* @param {string} episodeId
* @returns {import('./PodcastEpisode').ChapterObject[]}
*/
getChapters(episodeId) {
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[Podcast] getChapters: episode not found`, episodeId)
return []
}
return structuredClone(episode.chapters) || []
}
getPlaybackTitle(episodeId) {
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[Podcast] getPlaybackTitle: episode not found`, episodeId)
return ''
}
return episode.title
}
getPlaybackAuthor() {
return this.author
}
getPlaybackDuration(episodeId) {
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[Podcast] getPlaybackDuration: episode not found`, episodeId)
return 0
}
return episode.duration
}
/**
*
* @returns {number} - Unix timestamp
*/
getLatestEpisodePublishedAt() {
return this.podcastEpisodes.reduce((latest, episode) => {
if (episode.publishedAt?.valueOf() > latest) {
return episode.publishedAt.valueOf()
}
return latest
}, 0)
}
/**
* Used for checking if an rss feed episode is already in the podcast
*
* @param {import('../utils/podcastUtils').RssPodcastEpisode} feedEpisode - object from rss feed
* @returns {boolean}
*/
checkHasEpisodeByFeedEpisode(feedEpisode) {
const guid = feedEpisode.guid
const url = feedEpisode.enclosure.url
return this.podcastEpisodes.some((ep) => ep.checkMatchesGuidOrEnclosureUrl(guid, url))
}
/**
* Old model kept metadata in a separate object
*/
oldMetadataToJSON() {
return {
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: [...(this.genres || [])],
feedUrl: this.feedURL,
imageUrl: this.imageURL,
itunesPageUrl: this.itunesPageURL,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language,
type: this.podcastType
}
}
oldMetadataToJSONExpanded() {
const oldMetadataJSON = this.oldMetadataToJSON()
oldMetadataJSON.titleIgnorePrefix = getTitlePrefixAtEnd(this.title)
return oldMetadataJSON
}
/**
* The old model stored episodes with the podcast object
*
* @param {string} libraryItemId
*/
toOldJSON(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
}
if (!this.podcastEpisodes) {
throw new Error(`[Podcast] Cannot convert to old JSON because episodes are not provided`)
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSON(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((episode) => episode.toOldJSON(libraryItemId)),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload
}
}
toOldJSONMinified() {
return {
id: this.id,
// Minified metadata and expanded metadata are the same
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
numEpisodes: this.podcastEpisodes?.length || 0,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
toOldJSONExpanded(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
}
if (!this.podcastEpisodes) {
throw new Error(`[Podcast] Cannot convert to old JSON because episodes are not provided`)
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
}
module.exports = Podcast

View file

@ -1,5 +1,4 @@
const { DataTypes, Model } = require('sequelize')
const oldPodcastEpisode = require('../objects/entities/PodcastEpisode')
/**
* @typedef ChapterObject
@ -54,75 +53,39 @@ class PodcastEpisode extends Model {
}
/**
* @param {string} libraryItemId
* @returns {oldPodcastEpisode}
*
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode
* @param {string} podcastId
* @param {import('../objects/files/AudioFile')} audioFile
*/
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 async createFromRssPodcastEpisode(rssPodcastEpisode, podcastId, audioFile) {
const podcastEpisode = {
index: null,
season: rssPodcastEpisode.season,
episode: rssPodcastEpisode.episode,
episodeType: rssPodcastEpisode.episodeType,
title: rssPodcastEpisode.title,
subtitle: rssPodcastEpisode.subtitle,
description: rssPodcastEpisode.description,
pubDate: rssPodcastEpisode.pubDate,
enclosureURL: rssPodcastEpisode.enclosure?.url || null,
enclosureSize: rssPodcastEpisode.enclosure?.length || null,
enclosureType: rssPodcastEpisode.enclosure?.type || null,
publishedAt: rssPodcastEpisode.publishedAt,
podcastId,
audioFile: audioFile.toJSON(),
chapters: [],
extraData: {}
}
if (rssPodcastEpisode.guid) {
podcastEpisode.extraData.guid = rssPodcastEpisode.guid
}
if (audioFile.chapters?.length) {
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))
}
return new oldPodcastEpisode({
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,
guid: this.extraData?.guid || null,
pubDate: this.pubDate,
chapters: this.chapters,
audioFile: this.audioFile,
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
static createFromOld(oldEpisode) {
const podcastEpisode = this.getFromOld(oldEpisode)
return this.create(podcastEpisode)
}
static getFromOld(oldEpisode) {
const extraData = {}
if (oldEpisode.oldEpisodeId) {
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
}
if (oldEpisode.guid) {
extraData.guid = oldEpisode.guid
}
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
}
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
@ -170,6 +133,92 @@ class PodcastEpisode extends Model {
})
PodcastEpisode.belongsTo(podcast)
}
get size() {
return this.audioFile?.metadata.size || 0
}
get duration() {
return this.audioFile?.duration || 0
}
/**
* Used for matching the episode with an episode in the RSS feed
*
* @param {string} guid
* @param {string} enclosureURL
* @returns {boolean}
*/
checkMatchesGuidOrEnclosureUrl(guid, enclosureURL) {
if (this.extraData?.guid && this.extraData.guid === guid) {
return true
}
if (this.enclosureURL && this.enclosureURL === enclosureURL) {
return true
}
return false
}
/**
* Used in client players
*
* @param {string} libraryItemId
* @returns {import('./Book').AudioTrack}
*/
getAudioTrack(libraryItemId) {
const track = structuredClone(this.audioFile)
track.startOffset = 0
track.title = this.audioFile.metadata.filename
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
return track
}
toOldJSON(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[PodcastEpisode] Cannot convert to old JSON because libraryItemId is not provided`)
}
let enclosure = null
if (this.enclosureURL) {
enclosure = {
url: this.enclosureURL,
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
}
}
return {
libraryItemId: libraryItemId,
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,
guid: this.extraData?.guid || null,
pubDate: this.pubDate,
chapters: structuredClone(this.chapters),
audioFile: structuredClone(this.audioFile),
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
}
}
toOldJSONExpanded(libraryItemId) {
const json = this.toOldJSON(libraryItemId)
json.audioTrack = this.getAudioTrack(libraryItemId)
json.size = this.size
json.duration = this.duration
return json
}
}
module.exports = PodcastEpisode

View file

@ -1,6 +1,6 @@
const { DataTypes, Model, where, fn, col } = require('sequelize')
const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
const { getTitlePrefixAtEnd } = require('../utils/index')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils/index')
class Series extends Model {
constructor(values, options) {
@ -20,6 +20,11 @@ class Series extends Model {
this.createdAt
/** @type {Date} */
this.updatedAt
// Expanded properties
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
this.books
}
/**
@ -49,6 +54,34 @@ class Series extends Model {
})
}
/**
*
* @param {string} seriesId
* @returns {Promise<Series>}
*/
static async getExpandedById(seriesId) {
const series = await this.findByPk(seriesId)
if (!series) return null
series.books = await series.getBooksExpandedWithLibraryItem()
return series
}
/**
*
* @param {string} seriesName
* @param {string} libraryId
* @returns {Promise<Series>}
*/
static async findOrCreateByNameAndLibrary(seriesName, libraryId) {
const series = await this.getByNameAndLibrary(seriesName, libraryId)
if (series) return series
return this.create({
name: seriesName,
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
libraryId
})
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
@ -103,6 +136,35 @@ class Series extends Model {
Series.belongsTo(library)
}
/**
* Get all books in collection expanded with library item
*
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
*/
getBooksExpandedWithLibraryItem() {
return this.getBooks({
joinTableAttributes: ['sequence'],
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [[literal('CAST(`bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]]
})
}
toOldJSON() {
return {
id: this.id,

View file

@ -563,9 +563,8 @@ class User extends Model {
/**
* Check user can access library item
* TODO: Currently supports both old and new library item models
*
* @param {import('../objects/LibraryItem')|import('./LibraryItem')} libraryItem
* @param {import('./LibraryItem')} libraryItem
* @returns {boolean}
*/
checkCanAccessLibraryItem(libraryItem) {

View file

@ -1,115 +0,0 @@
const uuidv4 = require("uuid").v4
class Collection {
constructor(collection) {
this.id = null
this.libraryId = null
this.name = null
this.description = null
this.cover = null
this.coverFullPath = null
this.books = []
this.lastUpdate = null
this.createdAt = null
if (collection) {
this.construct(collection)
}
}
toJSON() {
return {
id: this.id,
libraryId: this.libraryId,
name: this.name,
description: this.description,
cover: this.cover,
coverFullPath: this.coverFullPath,
books: [...this.books],
lastUpdate: this.lastUpdate,
createdAt: this.createdAt
}
}
toJSONExpanded(libraryItems, minifiedBooks = false) {
const json = this.toJSON()
json.books = json.books.map(bookId => {
const book = libraryItems.find(li => li.id === bookId)
return book ? minifiedBooks ? book.toJSONMinified() : book.toJSONExpanded() : null
}).filter(b => !!b)
return json
}
// Expanded and filtered out items not accessible to user
toJSONExpandedForUser(user, libraryItems) {
const json = this.toJSON()
json.books = json.books.map(libraryItemId => {
const libraryItem = libraryItems.find(li => li.id === libraryItemId)
return libraryItem ? libraryItem.toJSONExpanded() : null
}).filter(li => {
return li && user.checkCanAccessLibraryItem(li)
})
return json
}
construct(collection) {
this.id = collection.id
this.libraryId = collection.libraryId
this.name = collection.name
this.description = collection.description || null
this.cover = collection.cover || null
this.coverFullPath = collection.coverFullPath || null
this.books = collection.books ? [...collection.books] : []
this.lastUpdate = collection.lastUpdate || null
this.createdAt = collection.createdAt || null
}
setData(data) {
if (!data.libraryId || !data.name) {
return false
}
this.id = uuidv4()
this.libraryId = data.libraryId
this.name = data.name
this.description = data.description || null
this.cover = data.cover || null
this.coverFullPath = data.coverFullPath || null
this.books = data.books ? [...data.books] : []
this.lastUpdate = Date.now()
this.createdAt = Date.now()
return true
}
addBook(bookId) {
this.books.push(bookId)
this.lastUpdate = Date.now()
}
removeBook(bookId) {
this.books = this.books.filter(bid => bid !== bookId)
this.lastUpdate = Date.now()
}
update(payload) {
let hasUpdates = false
for (const key in payload) {
if (key === 'books') {
if (payload.books && this.books.join(',') !== payload.books.join(',')) {
this.books = [...payload.books]
hasUpdates = true
}
} else if (this[key] !== undefined && this[key] !== payload[key]) {
hasUpdates = true
this[key] = payload[key]
}
}
if (hasUpdates) {
this.lastUpdate = Date.now()
}
return hasUpdates
}
}
module.exports = Collection

View file

@ -1,427 +0,0 @@
const Path = require('path')
const uuidv4 = require('uuid').v4
const FeedMeta = require('./FeedMeta')
const FeedEpisode = require('./FeedEpisode')
const date = require('../libs/dateAndTime')
const RSS = require('../libs/rss')
const { createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
class Feed {
constructor(feed) {
this.id = null
this.slug = null
this.userId = null
this.entityType = null
this.entityId = null
this.entityUpdatedAt = null
this.coverPath = null
this.serverAddress = null
this.feedUrl = null
this.meta = null
this.episodes = null
this.createdAt = null
this.updatedAt = null
// Cached xml
this.xml = null
if (feed) {
this.construct(feed)
}
}
construct(feed) {
this.id = feed.id
this.slug = feed.slug
this.userId = feed.userId
this.entityType = feed.entityType
this.entityId = feed.entityId
this.entityUpdatedAt = feed.entityUpdatedAt
this.coverPath = feed.coverPath
this.serverAddress = feed.serverAddress
this.feedUrl = feed.feedUrl
this.meta = new FeedMeta(feed.meta)
this.episodes = feed.episodes.map((ep) => new FeedEpisode(ep))
this.createdAt = feed.createdAt
this.updatedAt = feed.updatedAt
}
toJSON() {
return {
id: this.id,
slug: this.slug,
userId: this.userId,
entityType: this.entityType,
entityId: this.entityId,
coverPath: this.coverPath,
serverAddress: this.serverAddress,
feedUrl: this.feedUrl,
meta: this.meta.toJSON(),
episodes: this.episodes.map((ep) => ep.toJSON()),
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
}
toJSONMinified() {
return {
id: this.id,
entityType: this.entityType,
entityId: this.entityId,
feedUrl: this.feedUrl,
meta: this.meta.toJSONMinified()
}
}
getEpisodePath(id) {
var episode = this.episodes.find((ep) => ep.id === id)
if (!episode) return null
return episode.fullPath
}
/**
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
*
* @param {import('../objects/LibraryItem')} libraryItem
* @returns {boolean}
*/
checkUseChapterTitlesForEpisodes(libraryItem) {
const tracks = libraryItem.media.tracks
const chapters = libraryItem.media.chapters
if (tracks.length !== chapters.length) return false
for (let i = 0; i < tracks.length; i++) {
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) {
return false
}
}
return true
}
setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const media = libraryItem.media
const mediaMetadata = media.metadata
const isPodcast = libraryItem.mediaType === 'podcast'
const feedUrl = `${serverAddress}/feed/${slug}`
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'libraryItem'
this.entityId = libraryItem.id
this.entityUpdatedAt = libraryItem.updatedAt
this.coverPath = media.coverPath || null
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = mediaMetadata.title
this.meta.description = mediaMetadata.description
this.meta.author = author
this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `${serverAddress}/item/${libraryItem.id}`
this.meta.explicit = !!mediaMetadata.explicit
this.meta.type = mediaMetadata.type
this.meta.language = mediaMetadata.language
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
if (isPodcast) {
// PODCAST EPISODES
media.episodes.forEach((episode) => {
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
const feedEpisode = new FeedEpisode()
feedEpisode.setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, this.meta)
this.episodes.push(feedEpisode)
})
} else {
// AUDIOBOOK EPISODES
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem)
media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
feedEpisode.setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, this.meta, useChapterTitles)
this.episodes.push(feedEpisode)
})
}
this.createdAt = Date.now()
this.updatedAt = Date.now()
}
updateFromItem(libraryItem) {
const media = libraryItem.media
const mediaMetadata = media.metadata
const isPodcast = libraryItem.mediaType === 'podcast'
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
this.entityUpdatedAt = libraryItem.updatedAt
this.coverPath = media.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta.title = mediaMetadata.title
this.meta.description = mediaMetadata.description
this.meta.author = author
this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
this.meta.explicit = !!mediaMetadata.explicit
this.meta.type = mediaMetadata.type
this.meta.language = mediaMetadata.language
this.episodes = []
if (isPodcast) {
// PODCAST EPISODES
media.episodes.forEach((episode) => {
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
const feedEpisode = new FeedEpisode()
feedEpisode.setFromPodcastEpisode(libraryItem, this.serverAddress, this.slug, episode, this.meta)
this.episodes.push(feedEpisode)
})
} else {
// AUDIOBOOK EPISODES
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem)
media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
feedEpisode.setFromAudiobookTrack(libraryItem, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles)
this.episodes.push(feedEpisode)
})
}
this.updatedAt = Date.now()
this.xml = null
}
setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const feedUrl = `${serverAddress}/feed/${slug}`
const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'collection'
this.entityId = collectionExpanded.id
this.entityUpdatedAt = collectionExpanded.lastUpdate // This will be set to the most recently updated library item
this.coverPath = firstItemWithCover?.media.coverPath || null
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = collectionExpanded.name
this.meta.description = collectionExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.createdAt = Date.now()
this.updatedAt = Date.now()
}
updateFromCollection(collectionExpanded) {
const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.entityUpdatedAt = collectionExpanded.lastUpdate
this.coverPath = firstItemWithCover?.media.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta.title = collectionExpanded.name
this.meta.description = collectionExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.updatedAt = Date.now()
this.xml = null
}
setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const feedUrl = `${serverAddress}/feed/${slug}`
let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
// Sort series items by series sequence
itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id))
const libraryId = itemsWithTracks[0].libraryId
const firstItemWithCover = itemsWithTracks.find((li) => li.media.coverPath)
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'series'
this.entityId = seriesExpanded.id
this.entityUpdatedAt = seriesExpanded.updatedAt // This will be set to the most recently updated library item
this.coverPath = firstItemWithCover?.media.coverPath || null
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = seriesExpanded.name
this.meta.description = seriesExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.createdAt = Date.now()
this.updatedAt = Date.now()
}
updateFromSeries(seriesExpanded) {
let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
// Sort series items by series sequence
itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id))
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.entityUpdatedAt = seriesExpanded.updatedAt
this.coverPath = firstItemWithCover?.media.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta.title = seriesExpanded.name
this.meta.description = seriesExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.updatedAt = Date.now()
this.xml = null
}
buildXml() {
if (this.xml) return this.xml
var rssfeed = new RSS(this.meta.getRSSData())
this.episodes.forEach((ep) => {
rssfeed.item(ep.getRSSData())
})
this.xml = rssfeed.xml()
return this.xml
}
getAuthorsStringFromLibraryItems(libraryItems) {
let itemAuthors = []
libraryItems.forEach((item) => itemAuthors.push(...item.media.metadata.authors.map((au) => au.name)))
itemAuthors = [...new Set(itemAuthors)] // Filter out dupes
let author = itemAuthors.slice(0, 3).join(', ')
if (itemAuthors.length > 3) {
author += ' & more'
}
return author
}
}
module.exports = Feed

View file

@ -1,177 +0,0 @@
const Path = require('path')
const uuidv4 = require('uuid').v4
const date = require('../libs/dateAndTime')
const { secondsToTimestamp } = require('../utils/index')
class FeedEpisode {
constructor(episode) {
this.id = null
this.title = null
this.description = null
this.enclosure = null
this.pubDate = null
this.link = null
this.author = null
this.explicit = null
this.duration = null
this.season = null
this.episode = null
this.episodeType = null
this.libraryItemId = null
this.episodeId = null
this.trackIndex = null
this.fullPath = null
if (episode) {
this.construct(episode)
}
}
construct(episode) {
this.id = episode.id
this.title = episode.title
this.description = episode.description
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
this.pubDate = episode.pubDate
this.link = episode.link
this.author = episode.author
this.explicit = episode.explicit
this.duration = episode.duration
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.libraryItemId = episode.libraryItemId
this.episodeId = episode.episodeId || null
this.trackIndex = episode.trackIndex || 0
this.fullPath = episode.fullPath
}
toJSON() {
return {
id: this.id,
title: this.title,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
pubDate: this.pubDate,
link: this.link,
author: this.author,
explicit: this.explicit,
duration: this.duration,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
libraryItemId: this.libraryItemId,
episodeId: this.episodeId,
trackIndex: this.trackIndex,
fullPath: this.fullPath
}
}
setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, meta) {
const contentFileExtension = Path.extname(episode.audioFile.metadata.filename)
const contentUrl = `/feed/${slug}/item/${episode.id}/media${contentFileExtension}`
const media = libraryItem.media
const mediaMetadata = media.metadata
this.id = episode.id
this.title = episode.title
this.description = episode.description || ''
this.enclosure = {
url: `${serverAddress}${contentUrl}`,
type: episode.audioTrack.mimeType,
size: episode.size
}
this.pubDate = episode.pubDate
this.link = meta.link
this.author = meta.author
this.explicit = mediaMetadata.explicit
this.duration = episode.duration
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.libraryItemId = libraryItem.id
this.episodeId = episode.id
this.trackIndex = 0
this.fullPath = episode.audioFile.metadata.path
}
/**
*
* @param {import('../objects/LibraryItem')} libraryItem
* @param {string} serverAddress
* @param {string} slug
* @param {import('../objects/files/AudioTrack')} audioTrack
* @param {Object} meta
* @param {boolean} useChapterTitles
* @param {string} [pubDateOverride] Used for series & collections to ensure correct episode order
*/
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, useChapterTitles, pubDateOverride = null) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
let episodeId = uuidv4()
// e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = pubDateOverride || date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentFileExtension = Path.extname(audioTrack.metadata.filename)
const contentUrl = `/feed/${slug}/item/${episodeId}/media${contentFileExtension}`
const media = libraryItem.media
const mediaMetadata = media.metadata
let title = audioTrack.title
if (libraryItem.media.tracks.length == 1) {
// If audiobook is a single file, use book title instead of chapter/file title
title = libraryItem.media.metadata.title
} else {
if (useChapterTitles) {
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
const matchingChapter = libraryItem.media.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
if (matchingChapter?.title) title = matchingChapter.title
}
}
this.id = episodeId
this.title = title
this.description = mediaMetadata.description || ''
this.enclosure = {
url: `${serverAddress}${contentUrl}`,
type: audioTrack.mimeType,
size: audioTrack.metadata.size
}
this.pubDate = audiobookPubDate
this.link = meta.link
this.author = meta.author
this.explicit = mediaMetadata.explicit
this.duration = audioTrack.duration
this.libraryItemId = libraryItem.id
this.episodeId = null
this.trackIndex = audioTrack.index
this.fullPath = audioTrack.metadata.path
}
getRSSData() {
return {
title: this.title,
description: this.description || '',
url: this.link,
guid: this.enclosure.url,
author: this.author,
date: this.pubDate,
enclosure: this.enclosure,
custom_elements: [
{ 'itunes:author': this.author },
{ 'itunes:duration': secondsToTimestamp(this.duration) },
{ 'itunes:summary': this.description || '' },
{
'itunes:explicit': !!this.explicit
},
{ 'itunes:episodeType': this.episodeType },
{ 'itunes:season': this.season },
{ 'itunes:episode': this.episode }
]
}
}
}
module.exports = FeedEpisode

View file

@ -1,106 +0,0 @@
class FeedMeta {
constructor(meta) {
this.title = null
this.description = null
this.author = null
this.imageUrl = null
this.feedUrl = null
this.link = null
this.explicit = null
this.type = null
this.language = null
this.preventIndexing = null
this.ownerName = null
this.ownerEmail = null
if (meta) {
this.construct(meta)
}
}
construct(meta) {
this.title = meta.title
this.description = meta.description
this.author = meta.author
this.imageUrl = meta.imageUrl
this.feedUrl = meta.feedUrl
this.link = meta.link
this.explicit = meta.explicit
this.type = meta.type
this.language = meta.language
this.preventIndexing = meta.preventIndexing
this.ownerName = meta.ownerName
this.ownerEmail = meta.ownerEmail
}
toJSON() {
return {
title: this.title,
description: this.description,
author: this.author,
imageUrl: this.imageUrl,
feedUrl: this.feedUrl,
link: this.link,
explicit: this.explicit,
type: this.type,
language: this.language,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
toJSONMinified() {
return {
title: this.title,
description: this.description,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
getRSSData() {
const blockTags = [
{ 'itunes:block': 'yes' },
{ 'googleplay:block': 'yes' }
]
return {
title: this.title,
description: this.description || '',
generator: 'Audiobookshelf',
feed_url: this.feedUrl,
site_url: this.link,
image_url: this.imageUrl,
custom_namespaces: {
'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',
'psc': 'http://podlove.org/simple-chapters',
'podcast': 'https://podcastindex.org/namespace/1.0',
'googleplay': 'http://www.google.com/schemas/play-podcasts/1.0'
},
custom_elements: [
{ 'language': this.language || 'en' },
{ 'author': this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:summary': this.description || '' },
{ 'itunes:type': this.type },
{
'itunes:image': {
_attr: {
href: this.imageUrl
}
}
},
{
'itunes:owner': [
{ 'itunes:name': this.ownerName || this.author || '' },
{ 'itunes:email': this.ownerEmail || '' }
]
},
{ 'itunes:explicit': !!this.explicit },
...(this.preventIndexing ? blockTags : [])
]
}
}
}
module.exports = FeedMeta

View file

@ -1,346 +0,0 @@
const uuidv4 = require('uuid').v4
const fs = require('../libs/fsExtra')
const Path = require('path')
const Logger = require('../Logger')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast')
const { areEquivalent, copyValue } = require('../utils/index')
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
class LibraryItem {
constructor(libraryItem = null) {
this.id = null
this.ino = null // Inode
this.oldLibraryItemId = null
this.libraryId = null
this.folderId = null
this.path = null
this.relPath = null
this.isFile = false
this.mtimeMs = null
this.ctimeMs = null
this.birthtimeMs = null
this.addedAt = null
this.updatedAt = null
this.lastScan = null
this.scanVersion = null
// Was scanned and no longer exists
this.isMissing = false
// Was scanned and no longer has media files
this.isInvalid = false
this.mediaType = null
this.media = null
/** @type {LibraryFile[]} */
this.libraryFiles = []
if (libraryItem) {
this.construct(libraryItem)
}
// Temporary attributes
this.isSavingMetadata = false
}
construct(libraryItem) {
this.id = libraryItem.id
this.ino = libraryItem.ino || null
this.oldLibraryItemId = libraryItem.oldLibraryItemId
this.libraryId = libraryItem.libraryId
this.folderId = libraryItem.folderId
this.path = libraryItem.path
this.relPath = libraryItem.relPath
this.isFile = !!libraryItem.isFile
this.mtimeMs = libraryItem.mtimeMs || 0
this.ctimeMs = libraryItem.ctimeMs || 0
this.birthtimeMs = libraryItem.birthtimeMs || 0
this.addedAt = libraryItem.addedAt
this.updatedAt = libraryItem.updatedAt || this.addedAt
this.lastScan = libraryItem.lastScan || null
this.scanVersion = libraryItem.scanVersion || null
this.isMissing = !!libraryItem.isMissing
this.isInvalid = !!libraryItem.isInvalid
this.mediaType = libraryItem.mediaType
if (this.mediaType === 'book') {
this.media = new Book(libraryItem.media)
} else if (this.mediaType === 'podcast') {
this.media = new Podcast(libraryItem.media)
}
this.media.libraryItemId = this.id
this.libraryFiles = libraryItem.libraryFiles.map((f) => new LibraryFile(f))
// Migration for v2.2.23 to set ebook library files as supplementary
if (this.isBook && this.media.ebookFile) {
for (const libraryFile of this.libraryFiles) {
if (libraryFile.isEBookFile && libraryFile.isSupplementary === null) {
libraryFile.isSupplementary = this.media.ebookFile.ino !== libraryFile.ino
}
}
}
}
toJSON() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
lastScan: this.lastScan,
scanVersion: this.scanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toJSON(),
libraryFiles: this.libraryFiles.map((f) => f.toJSON())
}
}
toJSONMinified() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toJSONMinified(),
numFiles: this.libraryFiles.length,
size: this.size
}
}
// Adds additional helpful fields like media duration, tracks, etc.
toJSONExpanded() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
lastScan: this.lastScan,
scanVersion: this.scanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toJSONExpanded(),
libraryFiles: this.libraryFiles.map((f) => f.toJSON()),
size: this.size
}
}
get isPodcast() {
return this.mediaType === 'podcast'
}
get isBook() {
return this.mediaType === 'book'
}
get size() {
let total = 0
this.libraryFiles.forEach((lf) => (total += lf.metadata.size))
return total
}
get hasAudioFiles() {
return this.libraryFiles.some((lf) => lf.fileType === 'audio')
}
get hasMediaEntities() {
return this.media.hasMediaEntities
}
// Data comes from scandir library item data
// TODO: Remove this function. Only used when creating a new podcast now
setData(libraryMediaType, payload) {
this.id = uuidv4()
this.mediaType = libraryMediaType
if (libraryMediaType === 'podcast') {
this.media = new Podcast()
} else {
Logger.error(`[LibraryItem] setData called with unsupported media type "${libraryMediaType}"`)
return
}
this.media.id = uuidv4()
this.media.libraryItemId = this.id
for (const key in payload) {
if (key === 'libraryFiles') {
this.libraryFiles = payload.libraryFiles.map((lf) => lf.clone())
// Set cover image
const imageFiles = this.libraryFiles.filter((lf) => lf.fileType === 'image')
const coverMatch = imageFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
if (coverMatch) {
this.media.coverPath = coverMatch.metadata.path
} else if (imageFiles.length) {
this.media.coverPath = imageFiles[0].metadata.path
}
} else if (this[key] !== undefined && key !== 'media') {
this[key] = payload[key]
}
}
if (payload.media) {
this.media.setData(payload.media)
}
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (key === 'media') {
if (this.media.update(payload[key])) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
hasUpdates = true
}
}
}
if (hasUpdates) {
this.updatedAt = Date.now()
}
return hasUpdates
}
updateMediaCover(coverPath) {
this.media.updateCover(coverPath)
this.updatedAt = Date.now()
return true
}
setMissing() {
this.isMissing = true
this.updatedAt = Date.now()
}
getDirectPlayTracklist(episodeId) {
return this.media.getDirectPlayTracklist(episodeId)
}
/**
* Save metadata.json file
* TODO: Move to new LibraryItem model
* @returns {Promise<LibraryFile>} null if not saved
*/
async saveMetadata() {
if (this.isSavingMetadata || !global.MetadataPath) return null
this.isSavingMetadata = true
let metadataPath = Path.join(global.MetadataPath, 'items', this.id)
let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem
if (storeMetadataWithItem && !this.isFile) {
metadataPath = this.path
} else {
// Make sure metadata book dir exists
storeMetadataWithItem = false
await fs.ensureDir(metadataPath)
}
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
return fs
.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2))
.then(async () => {
// Add metadata.json to libraryFiles array if it is new
let metadataLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))
if (storeMetadataWithItem) {
if (!metadataLibraryFile) {
metadataLibraryFile = new LibraryFile()
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
this.libraryFiles.push(metadataLibraryFile)
} else {
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
if (fileTimestamps) {
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
metadataLibraryFile.metadata.size = fileTimestamps.size
metadataLibraryFile.ino = fileTimestamps.ino
}
}
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
if (libraryItemDirTimestamps) {
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
}
}
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
return metadataLibraryFile
})
.catch((error) => {
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
return null
})
.finally(() => {
this.isSavingMetadata = false
})
}
removeLibraryFile(ino) {
if (!ino) return false
const libraryFile = this.libraryFiles.find((lf) => lf.ino === ino)
if (libraryFile) {
this.libraryFiles = this.libraryFiles.filter((lf) => lf.ino !== ino)
this.updatedAt = Date.now()
return true
}
return false
}
/**
* Set the EBookFile from a LibraryFile
* If null then ebookFile will be removed from the book
* all ebook library files that are not primary are marked as supplementary
*
* @param {LibraryFile} [libraryFile]
*/
setPrimaryEbook(ebookLibraryFile = null) {
const ebookLibraryFiles = this.libraryFiles.filter((lf) => lf.isEBookFile)
for (const libraryFile of ebookLibraryFiles) {
libraryFile.isSupplementary = ebookLibraryFile?.ino !== libraryFile.ino
}
this.media.setEbookFile(ebookLibraryFile)
}
}
module.exports = LibraryItem

View file

@ -1,8 +1,6 @@
const date = require('../libs/dateAndTime')
const uuidv4 = require('uuid').v4
const serverVersion = require('../../package.json').version
const BookMetadata = require('./metadata/BookMetadata')
const PodcastMetadata = require('./metadata/PodcastMetadata')
const DeviceInfo = require('./DeviceInfo')
class PlaybackSession {
@ -60,7 +58,7 @@ class PlaybackSession {
bookId: this.bookId,
episodeId: this.episodeId,
mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata?.toJSON() || null,
mediaMetadata: structuredClone(this.mediaMetadata),
chapters: (this.chapters || []).map((c) => ({ ...c })),
displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor,
@ -82,7 +80,7 @@ class PlaybackSession {
/**
* Session data to send to clients
* @param {Object} [libraryItem] - old library item
* @param {import('../models/LibraryItem')} [libraryItem]
* @returns
*/
toJSONForClient(libraryItem) {
@ -94,7 +92,7 @@ class PlaybackSession {
bookId: this.bookId,
episodeId: this.episodeId,
mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata?.toJSON() || null,
mediaMetadata: structuredClone(this.mediaMetadata),
chapters: (this.chapters || []).map((c) => ({ ...c })),
displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor,
@ -112,7 +110,7 @@ class PlaybackSession {
startedAt: this.startedAt,
updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }),
libraryItem: libraryItem?.toJSONExpanded() || null
libraryItem: libraryItem?.toOldJSONExpanded() || null
}
}
@ -148,14 +146,7 @@ class PlaybackSession {
this.serverVersion = session.serverVersion
this.chapters = session.chapters || []
this.mediaMetadata = null
if (session.mediaMetadata) {
if (this.mediaType === 'book') {
this.mediaMetadata = new BookMetadata(session.mediaMetadata)
} else if (this.mediaType === 'podcast') {
this.mediaMetadata = new PodcastMetadata(session.mediaMetadata)
}
}
this.mediaMetadata = session.mediaMetadata
this.displayTitle = session.displayTitle || ''
this.displayAuthor = session.displayAuthor || ''
this.coverPath = session.coverPath
@ -205,6 +196,15 @@ class PlaybackSession {
}
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} userId
* @param {*} mediaPlayer
* @param {*} deviceInfo
* @param {*} startTime
* @param {*} episodeId
*/
setData(libraryItem, userId, mediaPlayer, deviceInfo, startTime, episodeId = null) {
this.id = uuidv4()
this.userId = userId
@ -213,13 +213,12 @@ class PlaybackSession {
this.bookId = episodeId ? null : libraryItem.media.id
this.episodeId = episodeId
this.mediaType = libraryItem.mediaType
this.mediaMetadata = libraryItem.media.metadata.clone()
this.mediaMetadata = libraryItem.media.oldMetadataToJSON()
this.chapters = libraryItem.media.getChapters(episodeId)
this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId)
this.displayAuthor = libraryItem.media.getPlaybackAuthor()
this.coverPath = libraryItem.media.coverPath
this.setDuration(libraryItem, episodeId)
this.duration = libraryItem.media.getPlaybackDuration(episodeId)
this.mediaPlayer = mediaPlayer
this.deviceInfo = deviceInfo || new DeviceInfo()
@ -235,14 +234,6 @@ class PlaybackSession {
this.updatedAt = Date.now()
}
setDuration(libraryItem, episodeId) {
if (episodeId) {
this.duration = libraryItem.media.getEpisodeDuration(episodeId)
} else {
this.duration = libraryItem.media.duration
}
}
addListeningTime(timeListened) {
if (!timeListened || isNaN(timeListened)) return

View file

@ -1,148 +0,0 @@
const uuidv4 = require("uuid").v4
class Playlist {
constructor(playlist) {
this.id = null
this.libraryId = null
this.userId = null
this.name = null
this.description = null
this.coverPath = null
// Array of objects like { libraryItemId: "", episodeId: "" } (episodeId optional)
this.items = []
this.lastUpdate = null
this.createdAt = null
if (playlist) {
this.construct(playlist)
}
}
toJSON() {
return {
id: this.id,
libraryId: this.libraryId,
userId: this.userId,
name: this.name,
description: this.description,
coverPath: this.coverPath,
items: [...this.items],
lastUpdate: this.lastUpdate,
createdAt: this.createdAt
}
}
// Expands the items array
toJSONExpanded(libraryItems) {
var json = this.toJSON()
json.items = json.items.map(item => {
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
if (!libraryItem) {
// Not found
return null
}
if (item.episodeId) {
if (!libraryItem.isPodcast) {
// Invalid
return null
}
const episode = libraryItem.media.episodes.find(ep => ep.id === item.episodeId)
if (!episode) {
// Not found
return null
}
return {
...item,
episode: episode.toJSONExpanded(),
libraryItem: libraryItem.toJSONMinified()
}
} else {
return {
...item,
libraryItem: libraryItem.toJSONExpanded()
}
}
}).filter(i => i)
return json
}
construct(playlist) {
this.id = playlist.id
this.libraryId = playlist.libraryId
this.userId = playlist.userId
this.name = playlist.name
this.description = playlist.description || null
this.coverPath = playlist.coverPath || null
this.items = playlist.items ? playlist.items.map(i => ({ ...i })) : []
this.lastUpdate = playlist.lastUpdate || null
this.createdAt = playlist.createdAt || null
}
setData(data) {
if (!data.userId || !data.libraryId || !data.name) {
return false
}
this.id = uuidv4()
this.userId = data.userId
this.libraryId = data.libraryId
this.name = data.name
this.description = data.description || null
this.coverPath = data.coverPath || null
this.items = data.items ? data.items.map(i => ({ ...i })) : []
this.lastUpdate = Date.now()
this.createdAt = Date.now()
return true
}
addItem(libraryItemId, episodeId = null) {
this.items.push({
libraryItemId,
episodeId: episodeId || null
})
this.lastUpdate = Date.now()
}
removeItem(libraryItemId, episodeId = null) {
if (episodeId) this.items = this.items.filter(i => i.libraryItemId !== libraryItemId || i.episodeId !== episodeId)
else this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
this.lastUpdate = Date.now()
}
update(payload) {
let hasUpdates = false
for (const key in payload) {
if (key === 'items') {
if (payload.items && JSON.stringify(payload.items) !== JSON.stringify(this.items)) {
this.items = payload.items.map(i => ({ ...i }))
hasUpdates = true
}
} else if (this[key] !== undefined && this[key] !== payload[key]) {
hasUpdates = true
this[key] = payload[key]
}
}
if (hasUpdates) {
this.lastUpdate = Date.now()
}
return hasUpdates
}
containsItem(item) {
if (item.episodeId) return this.items.some(i => i.libraryItemId === item.libraryItemId && i.episodeId === item.episodeId)
return this.items.some(i => i.libraryItemId === item.libraryItemId)
}
hasItemsForLibraryItem(libraryItemId) {
return this.items.some(i => i.libraryItemId === libraryItemId)
}
removeItemsForLibraryItem(libraryItemId) {
this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
}
}
module.exports = Playlist

View file

@ -6,8 +6,11 @@ const globals = require('../utils/globals')
class PodcastEpisodeDownload {
constructor() {
this.id = null
this.podcastEpisode = null
/** @type {import('../utils/podcastUtils').RssPodcastEpisode} */
this.rssPodcastEpisode = null
this.url = null
/** @type {import('../models/LibraryItem')} */
this.libraryItem = null
this.libraryId = null
@ -15,7 +18,7 @@ class PodcastEpisodeDownload {
this.isFinished = false
this.failed = false
this.appendEpisodeId = false
this.appendRandomId = false
this.startedAt = null
this.createdAt = null
@ -25,22 +28,22 @@ class PodcastEpisodeDownload {
toJSONForClient() {
return {
id: this.id,
episodeDisplayTitle: this.podcastEpisode?.title ?? null,
episodeDisplayTitle: this.rssPodcastEpisode?.title ?? null,
url: this.url,
libraryItemId: this.libraryItem?.id || null,
libraryItemId: this.libraryItemId,
libraryId: this.libraryId || null,
isFinished: this.isFinished,
failed: this.failed,
appendEpisodeId: this.appendEpisodeId,
appendRandomId: this.appendRandomId,
startedAt: this.startedAt,
createdAt: this.createdAt,
finishedAt: this.finishedAt,
podcastTitle: this.libraryItem?.media.metadata.title ?? null,
podcastExplicit: !!this.libraryItem?.media.metadata.explicit,
season: this.podcastEpisode?.season ?? null,
episode: this.podcastEpisode?.episode ?? null,
episodeType: this.podcastEpisode?.episodeType ?? 'full',
publishedAt: this.podcastEpisode?.publishedAt ?? null
podcastTitle: this.libraryItem?.media.title ?? null,
podcastExplicit: !!this.libraryItem?.media.explicit,
season: this.rssPodcastEpisode?.season ?? null,
episode: this.rssPodcastEpisode?.episode ?? null,
episodeType: this.rssPodcastEpisode?.episodeType ?? 'full',
publishedAt: this.rssPodcastEpisode?.publishedAt ?? null
}
}
@ -53,10 +56,26 @@ class PodcastEpisodeDownload {
if (globals.SupportedAudioTypes.includes(extname)) return extname
return 'mp3'
}
get enclosureType() {
const enclosureType = this.rssPodcastEpisode.enclosure.type
return typeof enclosureType === 'string' ? enclosureType : null
}
/**
* RSS feed may have an episode with file extension of mp3 but the specified enclosure type is not mpeg.
* @see https://github.com/advplyr/audiobookshelf/issues/3711
*
* @returns {boolean}
*/
get isMp3() {
if (this.enclosureType && !this.enclosureType.includes('mpeg')) return false
return this.fileExtension === 'mp3'
}
get episodeTitle() {
return this.rssPodcastEpisode.title
}
get targetFilename() {
const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : ''
const filename = `${this.podcastEpisode.title}${appendage}.${this.fileExtension}`
const appendage = this.appendRandomId ? ` (${uuidv4()})` : ''
const filename = `${this.rssPodcastEpisode.title}${appendage}.${this.fileExtension}`
return sanitizeFilename(filename)
}
get targetPath() {
@ -66,14 +85,25 @@ class PodcastEpisodeDownload {
return this.targetFilename
}
get libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
return this.libraryItem?.id || null
}
get pubYear() {
if (!this.rssPodcastEpisode.publishedAt) return null
return new Date(this.rssPodcastEpisode.publishedAt).getFullYear()
}
setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) {
/**
*
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode - from rss feed
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} isAutoDownload
* @param {*} libraryId
*/
setData(rssPodcastEpisode, libraryItem, isAutoDownload, libraryId) {
this.id = uuidv4()
this.podcastEpisode = podcastEpisode
this.rssPodcastEpisode = rssPodcastEpisode
const url = podcastEpisode.enclosure.url
const url = rssPodcastEpisode.enclosure.url
if (decodeURIComponent(url) !== url) {
// Already encoded
this.url = url

View file

@ -18,6 +18,7 @@ class Stream extends EventEmitter {
this.id = sessionId
this.user = user
/** @type {import('../models/LibraryItem')} */
this.libraryItem = libraryItem
this.episodeId = episodeId
@ -40,31 +41,25 @@ class Stream extends EventEmitter {
this.furthestSegmentCreated = 0
}
get isPodcast() {
return this.libraryItem.mediaType === 'podcast'
}
/**
* @returns {import('../models/PodcastEpisode') | null}
*/
get episode() {
if (!this.isPodcast) return null
return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId)
}
get libraryItemId() {
return this.libraryItem.id
if (!this.libraryItem.isPodcast) return null
return this.libraryItem.media.podcastEpisodes.find((ep) => ep.id === this.episodeId)
}
get mediaTitle() {
if (this.episode) return this.episode.title || ''
return this.libraryItem.media.metadata.title || ''
return this.libraryItem.media.getPlaybackTitle(this.episodeId)
}
get totalDuration() {
if (this.episode) return this.episode.duration
return this.libraryItem.media.duration
return this.libraryItem.media.getPlaybackDuration(this.episodeId)
}
get tracks() {
if (this.episode) return this.episode.tracks
return this.libraryItem.media.tracks
return this.libraryItem.getTrackList(this.episodeId)
}
get tracksAudioFileType() {
if (!this.tracks.length) return null
return this.tracks[0].metadata.format
return this.tracks[0].metadata.ext.slice(1)
}
get tracksMimeType() {
if (!this.tracks.length) return null
@ -116,8 +111,8 @@ class Stream extends EventEmitter {
return {
id: this.id,
userId: this.user.id,
libraryItem: this.libraryItem.toJSONExpanded(),
episode: this.episode ? this.episode.toJSONExpanded() : null,
libraryItem: this.libraryItem.toOldJSONExpanded(),
episode: this.episode ? this.episode.toOldJSONExpanded(this.libraryItem.id) : null,
segmentLength: this.segmentLength,
playlistPath: this.playlistPath,
clientPlaylistUri: this.clientPlaylistUri,
@ -280,15 +275,15 @@ class Stream extends EventEmitter {
this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`])
const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0']
this.ffmpeg.addOption(hlsOptions)
if (this.hlsSegmentType === 'fmp4') {
hlsOptions.push('-strict -2')
this.ffmpeg.addOption('-strict -2')
var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
// var fmp4InitFilename = 'init.mp4'
hlsOptions.push(`-hls_fmp4_init_filename ${fmp4InitFilename}`)
this.ffmpeg.addOption('-hls_fmp4_init_filename', fmp4InitFilename)
}
this.ffmpeg.addOption(hlsOptions)
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
this.ffmpeg.addOption('-hls_segment_filename', segmentFilename)
this.ffmpeg.output(this.finalPlaylistPath)
this.ffmpeg.on('start', (command) => {

View file

@ -1,186 +0,0 @@
const uuidv4 = require('uuid').v4
const { areEquivalent, copyValue } = require('../../utils/index')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
class PodcastEpisode {
constructor(episode) {
this.libraryItemId = null
this.podcastId = null
this.id = null
this.oldEpisodeId = null
this.index = null
this.season = null
this.episode = null
this.episodeType = null
this.title = null
this.subtitle = null
this.description = null
this.enclosure = null
this.guid = null
this.pubDate = null
this.chapters = []
this.audioFile = null
this.publishedAt = null
this.addedAt = null
this.updatedAt = null
if (episode) {
this.construct(episode)
}
}
construct(episode) {
this.libraryItemId = episode.libraryItemId
this.podcastId = episode.podcastId
this.id = episode.id
this.oldEpisodeId = episode.oldEpisodeId
this.index = episode.index
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.title = episode.title
this.subtitle = episode.subtitle
this.description = episode.description
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
this.guid = episode.guid || null
this.pubDate = episode.pubDate
this.chapters = episode.chapters?.map((ch) => ({ ...ch })) || []
this.audioFile = episode.audioFile ? new AudioFile(episode.audioFile) : null
this.publishedAt = episode.publishedAt
this.addedAt = episode.addedAt
this.updatedAt = episode.updatedAt
if (this.audioFile) {
this.audioFile.index = 1 // Only 1 audio file per episode
}
}
toJSON() {
return {
libraryItemId: this.libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
guid: this.guid,
pubDate: this.pubDate,
chapters: this.chapters.map((ch) => ({ ...ch })),
audioFile: this.audioFile?.toJSON() || null,
publishedAt: this.publishedAt,
addedAt: this.addedAt,
updatedAt: this.updatedAt
}
}
toJSONExpanded() {
return {
libraryItemId: this.libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
guid: this.guid,
pubDate: this.pubDate,
chapters: this.chapters.map((ch) => ({ ...ch })),
audioFile: this.audioFile?.toJSON() || null,
audioTrack: this.audioTrack?.toJSON() || null,
publishedAt: this.publishedAt,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
duration: this.duration,
size: this.size
}
}
get audioTrack() {
if (!this.audioFile) return null
const audioTrack = new AudioTrack()
audioTrack.setData(this.libraryItemId, this.audioFile, 0)
return audioTrack
}
get tracks() {
return [this.audioTrack]
}
get duration() {
return this.audioFile?.duration || 0
}
get size() {
return this.audioFile?.metadata.size || 0
}
get enclosureUrl() {
return this.enclosure?.url || null
}
get pubYear() {
if (!this.publishedAt) return null
return new Date(this.publishedAt).getFullYear()
}
setData(data, index = 1) {
this.id = uuidv4()
this.index = index
this.title = data.title
this.subtitle = data.subtitle || ''
this.pubDate = data.pubDate || ''
this.description = data.description || ''
this.enclosure = data.enclosure ? { ...data.enclosure } : null
this.guid = data.guid || null
this.season = data.season || ''
this.episode = data.episode || ''
this.episodeType = data.episodeType || 'full'
this.publishedAt = data.publishedAt || 0
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
update(payload) {
let hasUpdates = false
for (const key in this.toJSON()) {
let newValue = payload[key]
if (newValue === '') newValue = null
let existingValue = this[key]
if (existingValue === '') existingValue = null
if (newValue != undefined && !areEquivalent(newValue, existingValue)) {
this[key] = copyValue(newValue)
hasUpdates = true
}
}
if (hasUpdates) {
this.updatedAt = Date.now()
}
return hasUpdates
}
// Only checks container format
checkCanDirectPlay(payload) {
const supportedMimeTypes = payload.supportedMimeTypes || []
return supportedMimeTypes.includes(this.audioFile.mimeType)
}
getDirectPlayTracklist() {
return this.tracks
}
checkEqualsEnclosureUrl(url) {
if (!this.enclosure?.url) return false
return this.enclosure.url == url
}
}
module.exports = PodcastEpisode

View file

@ -1,274 +0,0 @@
const Logger = require('../../Logger')
const BookMetadata = require('../metadata/BookMetadata')
const { areEquivalent, copyValue } = require('../../utils/index')
const { filePathToPOSIX } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
const EBookFile = require('../files/EBookFile')
class Book {
constructor(book) {
this.id = null
this.libraryItemId = null
this.metadata = null
this.coverPath = null
this.tags = []
this.audioFiles = []
this.chapters = []
this.ebookFile = null
this.lastCoverSearch = null
this.lastCoverSearchQuery = null
if (book) {
this.construct(book)
}
}
construct(book) {
this.id = book.id
this.libraryItemId = book.libraryItemId
this.metadata = new BookMetadata(book.metadata)
this.coverPath = book.coverPath
this.tags = [...book.tags]
this.audioFiles = book.audioFiles.map(f => new AudioFile(f))
this.chapters = book.chapters.map(c => ({ ...c }))
this.ebookFile = book.ebookFile ? new EBookFile(book.ebookFile) : null
this.lastCoverSearch = book.lastCoverSearch || null
this.lastCoverSearchQuery = book.lastCoverSearchQuery || null
}
toJSON() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
tags: [...this.tags],
audioFiles: this.audioFiles.map(f => f.toJSON()),
chapters: this.chapters.map(c => ({ ...c })),
ebookFile: this.ebookFile ? this.ebookFile.toJSON() : null
}
}
toJSONMinified() {
return {
id: this.id,
metadata: this.metadata.toJSONMinified(),
coverPath: this.coverPath,
tags: [...this.tags],
numTracks: this.tracks.length,
numAudioFiles: this.audioFiles.length,
numChapters: this.chapters.length,
duration: this.duration,
size: this.size,
ebookFormat: this.ebookFile?.ebookFormat
}
}
toJSONExpanded() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
audioFiles: this.audioFiles.map(f => f.toJSON()),
chapters: this.chapters.map(c => ({ ...c })),
duration: this.duration,
size: this.size,
tracks: this.tracks.map(t => t.toJSON()),
ebookFile: this.ebookFile?.toJSON() || null
}
}
toJSONForMetadataFile() {
return {
tags: [...this.tags],
chapters: this.chapters.map(c => ({ ...c })),
...this.metadata.toJSONForMetadataFile()
}
}
get size() {
var total = 0
this.audioFiles.forEach((af) => total += af.metadata.size)
if (this.ebookFile) {
total += this.ebookFile.metadata.size
}
return total
}
get hasMediaEntities() {
return !!this.tracks.length || this.ebookFile
}
get includedAudioFiles() {
return this.audioFiles.filter(af => !af.exclude)
}
get tracks() {
let startOffset = 0
return this.includedAudioFiles.map((af) => {
const audioTrack = new AudioTrack()
audioTrack.setData(this.libraryItemId, af, startOffset)
startOffset += audioTrack.duration
return audioTrack
})
}
get duration() {
let total = 0
this.tracks.forEach((track) => total += track.duration)
return total
}
get numTracks() {
return this.tracks.length
}
get isEBookOnly() {
return this.ebookFile && !this.numTracks
}
update(payload) {
const json = this.toJSON()
delete json.audiobooks // do not update media entities here
delete json.ebooks
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (key === 'metadata') {
if (this.metadata.update(payload.metadata)) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[Book] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
updateChapters(chapters) {
var hasUpdates = this.chapters.length !== chapters.length
if (hasUpdates) {
this.chapters = chapters.map(ch => ({
id: ch.id,
start: ch.start,
end: ch.end,
title: ch.title
}))
} else {
for (let i = 0; i < this.chapters.length; i++) {
const currChapter = this.chapters[i]
const newChapter = chapters[i]
if (!hasUpdates && (currChapter.title !== newChapter.title || currChapter.start !== newChapter.start || currChapter.end !== newChapter.end)) {
hasUpdates = true
}
this.chapters[i].title = newChapter.title
this.chapters[i].start = newChapter.start
this.chapters[i].end = newChapter.end
}
}
return hasUpdates
}
updateCover(coverPath) {
coverPath = filePathToPOSIX(coverPath)
if (this.coverPath === coverPath) return false
this.coverPath = coverPath
return true
}
removeFileWithInode(inode) {
if (this.audioFiles.some(af => af.ino === inode)) {
this.audioFiles = this.audioFiles.filter(af => af.ino !== inode)
return true
}
if (this.ebookFile && this.ebookFile.ino === inode) {
this.ebookFile = null
return true
}
return false
}
/**
* Get audio file or ebook file from inode
* @param {string} inode
* @returns {(AudioFile|EBookFile|null)}
*/
findFileWithInode(inode) {
const audioFile = this.audioFiles.find(af => af.ino === inode)
if (audioFile) return audioFile
if (this.ebookFile && this.ebookFile.ino === inode) return this.ebookFile
return null
}
/**
* Set the EBookFile from a LibraryFile
* If null then ebookFile will be removed from the book
*
* @param {LibraryFile} [libraryFile]
*/
setEbookFile(libraryFile = null) {
if (!libraryFile) {
this.ebookFile = null
} else {
const ebookFile = new EBookFile()
ebookFile.setData(libraryFile)
this.ebookFile = ebookFile
}
}
addAudioFile(audioFile) {
this.audioFiles.push(audioFile)
}
updateAudioTracks(orderedFileData) {
let index = 1
this.audioFiles = orderedFileData.map((fileData) => {
const audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
audioFile.manuallyVerified = true
audioFile.error = null
if (fileData.exclude !== undefined) {
audioFile.exclude = !!fileData.exclude
}
if (audioFile.exclude) {
audioFile.index = -1
} else {
audioFile.index = index++
}
return audioFile
})
this.rebuildTracks()
}
rebuildTracks() {
Logger.debug(`[Book] Tracks being rebuilt...!`)
this.audioFiles.sort((a, b) => a.index - b.index)
}
// Only checks container format
checkCanDirectPlay(payload) {
var supportedMimeTypes = payload.supportedMimeTypes || []
return !this.tracks.some((t) => !supportedMimeTypes.includes(t.mimeType))
}
getDirectPlayTracklist() {
return this.tracks
}
getPlaybackTitle() {
return this.metadata.title
}
getPlaybackAuthor() {
return this.metadata.authorName
}
getChapters() {
return this.chapters?.map(ch => ({ ...ch })) || []
}
}
module.exports = Book

View file

@ -1,273 +0,0 @@
const Logger = require('../../Logger')
const PodcastEpisode = require('../entities/PodcastEpisode')
const PodcastMetadata = require('../metadata/PodcastMetadata')
const { areEquivalent, copyValue } = require('../../utils/index')
const { filePathToPOSIX } = require('../../utils/fileUtils')
class Podcast {
constructor(podcast) {
this.id = null
this.libraryItemId = null
this.metadata = null
this.coverPath = null
this.tags = []
this.episodes = []
this.autoDownloadEpisodes = false
this.autoDownloadSchedule = null
this.lastEpisodeCheck = 0
this.maxEpisodesToKeep = 0
this.maxNewEpisodesToDownload = 3
this.lastCoverSearch = null
this.lastCoverSearchQuery = null
if (podcast) {
this.construct(podcast)
}
}
construct(podcast) {
this.id = podcast.id
this.libraryItemId = podcast.libraryItemId
this.metadata = new PodcastMetadata(podcast.metadata)
this.coverPath = podcast.coverPath
this.tags = [...podcast.tags]
this.episodes = podcast.episodes.map((e) => {
var podcastEpisode = new PodcastEpisode(e)
podcastEpisode.libraryItemId = this.libraryItemId
return podcastEpisode
})
this.autoDownloadEpisodes = !!podcast.autoDownloadEpisodes
this.autoDownloadSchedule = podcast.autoDownloadSchedule || '0 * * * *' // Added in 2.1.3 so default to hourly
this.lastEpisodeCheck = podcast.lastEpisodeCheck || 0
this.maxEpisodesToKeep = podcast.maxEpisodesToKeep || 0
// Default is 3 but 0 is allowed
if (typeof podcast.maxNewEpisodesToDownload !== 'number') {
this.maxNewEpisodesToDownload = 3
} else {
this.maxNewEpisodesToDownload = podcast.maxNewEpisodesToDownload
}
}
toJSON() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map((e) => e.toJSON()),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload
}
}
toJSONMinified() {
return {
id: this.id,
metadata: this.metadata.toJSONMinified(),
coverPath: this.coverPath,
tags: [...this.tags],
numEpisodes: this.episodes.length,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
toJSONExpanded() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map((e) => e.toJSONExpanded()),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
toJSONForMetadataFile() {
return {
tags: [...this.tags],
title: this.metadata.title,
author: this.metadata.author,
description: this.metadata.description,
releaseDate: this.metadata.releaseDate,
genres: [...this.metadata.genres],
feedURL: this.metadata.feedUrl,
imageURL: this.metadata.imageUrl,
itunesPageURL: this.metadata.itunesPageUrl,
itunesId: this.metadata.itunesId,
itunesArtistId: this.metadata.itunesArtistId,
explicit: this.metadata.explicit,
language: this.metadata.language,
podcastType: this.metadata.type
}
}
get size() {
var total = 0
this.episodes.forEach((ep) => (total += ep.size))
return total
}
get hasMediaEntities() {
return !!this.episodes.length
}
get duration() {
let total = 0
this.episodes.forEach((ep) => (total += ep.duration))
return total
}
get numTracks() {
return this.episodes.length
}
get latestEpisodePublished() {
var largestPublishedAt = 0
this.episodes.forEach((ep) => {
if (ep.publishedAt && ep.publishedAt > largestPublishedAt) {
largestPublishedAt = ep.publishedAt
}
})
return largestPublishedAt
}
get episodesWithPubDate() {
return this.episodes.filter((ep) => !!ep.publishedAt)
}
update(payload) {
var json = this.toJSON()
delete json.episodes // do not update media entities here
var hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (key === 'metadata') {
if (this.metadata.update(payload.metadata)) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[Podcast] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
updateEpisode(id, payload) {
var episode = this.episodes.find((ep) => ep.id == id)
if (!episode) return false
return episode.update(payload)
}
updateCover(coverPath) {
coverPath = filePathToPOSIX(coverPath)
if (this.coverPath === coverPath) return false
this.coverPath = coverPath
return true
}
removeFileWithInode(inode) {
const hasEpisode = this.episodes.some((ep) => ep.audioFile.ino === inode)
if (hasEpisode) {
this.episodes = this.episodes.filter((ep) => ep.audioFile.ino !== inode)
}
return hasEpisode
}
findFileWithInode(inode) {
var episode = this.episodes.find((ep) => ep.audioFile.ino === inode)
if (episode) return episode.audioFile
return null
}
setData(mediaData) {
this.metadata = new PodcastMetadata()
if (mediaData.metadata) {
this.metadata.setData(mediaData.metadata)
}
this.coverPath = mediaData.coverPath || null
this.autoDownloadEpisodes = !!mediaData.autoDownloadEpisodes
this.autoDownloadSchedule = mediaData.autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule
this.lastEpisodeCheck = Date.now() // Makes sure new episodes are after this
}
checkHasEpisode(episodeId) {
return this.episodes.some((ep) => ep.id === episodeId)
}
checkHasEpisodeByFeedEpisode(feedEpisode) {
const guid = feedEpisode.guid
const url = feedEpisode.enclosure.url
return this.episodes.some((ep) => (ep.guid && ep.guid === guid) || ep.checkEqualsEnclosureUrl(url))
}
// Only checks container format
checkCanDirectPlay(payload, episodeId) {
var episode = this.episodes.find((ep) => ep.id === episodeId)
if (!episode) return false
return episode.checkCanDirectPlay(payload)
}
getDirectPlayTracklist(episodeId) {
var episode = this.episodes.find((ep) => ep.id === episodeId)
if (!episode) return false
return episode.getDirectPlayTracklist()
}
addPodcastEpisode(podcastEpisode) {
this.episodes.push(podcastEpisode)
}
removeEpisode(episodeId) {
const episode = this.episodes.find((ep) => ep.id === episodeId)
if (episode) {
this.episodes = this.episodes.filter((ep) => ep.id !== episodeId)
}
return episode
}
getPlaybackTitle(episodeId) {
var episode = this.episodes.find((ep) => ep.id == episodeId)
if (!episode) return this.metadata.title
return episode.title
}
getPlaybackAuthor() {
return this.metadata.author
}
getEpisodeDuration(episodeId) {
var episode = this.episodes.find((ep) => ep.id == episodeId)
if (!episode) return 0
return episode.duration
}
getEpisode(episodeId) {
if (!episodeId) return null
// Support old episode ids for mobile downloads
if (episodeId.startsWith('ep_')) return this.episodes.find((ep) => ep.oldEpisodeId == episodeId)
return this.episodes.find((ep) => ep.id == episodeId)
}
getChapters(episodeId) {
return this.getEpisode(episodeId)?.chapters?.map((ch) => ({ ...ch })) || []
}
}
module.exports = Podcast

View file

@ -1,184 +0,0 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
const parseNameString = require('../../utils/parsers/parseNameString')
class BookMetadata {
constructor(metadata) {
this.title = null
this.subtitle = null
this.authors = []
this.narrators = [] // Array of strings
this.series = []
this.genres = [] // Array of strings
this.publishedYear = null
this.publishedDate = null
this.publisher = null
this.description = null
this.isbn = null
this.asin = null
this.language = null
this.explicit = false
this.abridged = false
if (metadata) {
this.construct(metadata)
}
}
construct(metadata) {
this.title = metadata.title
this.subtitle = metadata.subtitle
this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : []
this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : []
this.series = metadata.series?.map
? metadata.series.map((s) => ({
...s,
name: s.name || 'No Title'
}))
: []
this.genres = metadata.genres ? [...metadata.genres] : []
this.publishedYear = metadata.publishedYear || null
this.publishedDate = metadata.publishedDate || null
this.publisher = metadata.publisher
this.description = metadata.description
this.isbn = metadata.isbn
this.asin = metadata.asin
this.language = metadata.language
this.explicit = !!metadata.explicit
this.abridged = !!metadata.abridged
}
toJSON() {
return {
title: this.title,
subtitle: this.subtitle,
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
narrators: [...this.narrators],
series: this.series.map((s) => ({ ...s })), // Series JSONMinimal with name, id and sequence
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
}
}
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titlePrefixAtEnd,
subtitle: this.subtitle,
authorName: this.authorName,
authorNameLF: this.authorNameLF,
narratorName: this.narratorName,
seriesName: this.seriesName,
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
}
}
toJSONExpanded() {
return {
title: this.title,
titleIgnorePrefix: this.titlePrefixAtEnd,
subtitle: this.subtitle,
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
narrators: [...this.narrators],
series: this.series.map((s) => ({ ...s })),
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
authorName: this.authorName,
authorNameLF: this.authorNameLF,
narratorName: this.narratorName,
seriesName: this.seriesName,
abridged: this.abridged
}
}
toJSONForMetadataFile() {
const json = this.toJSON()
json.authors = json.authors.map((au) => au.name)
json.series = json.series.map((se) => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
})
return json
}
clone() {
return new BookMetadata(this.toJSON())
}
get titleIgnorePrefix() {
return getTitleIgnorePrefix(this.title)
}
get titlePrefixAtEnd() {
return getTitlePrefixAtEnd(this.title)
}
get authorName() {
if (!this.authors.length) return ''
return this.authors.map((au) => au.name).join(', ')
}
get authorNameLF() {
// Last, First
if (!this.authors.length) return ''
return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')
}
get seriesName() {
if (!this.series.length) return ''
return this.series
.map((se) => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
})
.join(', ')
}
get narratorName() {
return this.narrators.join(', ')
}
getSeries(seriesId) {
return this.series.find((se) => se.id == seriesId)
}
getSeriesSequence(seriesId) {
const series = this.series.find((se) => se.id == seriesId)
if (!series) return null
return series.sequence || ''
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[BookMetadata] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = BookMetadata

View file

@ -1,127 +0,0 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
class PodcastMetadata {
constructor(metadata) {
this.title = null
this.author = null
this.description = null
this.releaseDate = null
this.genres = []
this.feedUrl = null
this.imageUrl = null
this.itunesPageUrl = null
this.itunesId = null
this.itunesArtistId = null
this.explicit = false
this.language = null
this.type = null
if (metadata) {
this.construct(metadata)
}
}
construct(metadata) {
this.title = metadata.title
this.author = metadata.author
this.description = metadata.description
this.releaseDate = metadata.releaseDate
this.genres = [...metadata.genres]
this.feedUrl = metadata.feedUrl
this.imageUrl = metadata.imageUrl
this.itunesPageUrl = metadata.itunesPageUrl
this.itunesId = metadata.itunesId
this.itunesArtistId = metadata.itunesArtistId
this.explicit = metadata.explicit
this.language = metadata.language || null
this.type = metadata.type || 'episodic'
}
toJSON() {
return {
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: [...this.genres],
feedUrl: this.feedUrl,
imageUrl: this.imageUrl,
itunesPageUrl: this.itunesPageUrl,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language,
type: this.type
}
}
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titlePrefixAtEnd,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: [...this.genres],
feedUrl: this.feedUrl,
imageUrl: this.imageUrl,
itunesPageUrl: this.itunesPageUrl,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language,
type: this.type
}
}
toJSONExpanded() {
return this.toJSONMinified()
}
clone() {
return new PodcastMetadata(this.toJSON())
}
get titleIgnorePrefix() {
return getTitleIgnorePrefix(this.title)
}
get titlePrefixAtEnd() {
return getTitlePrefixAtEnd(this.title)
}
setData(mediaMetadata = {}) {
this.title = mediaMetadata.title || null
this.author = mediaMetadata.author || null
this.description = mediaMetadata.description || null
this.releaseDate = mediaMetadata.releaseDate || null
this.feedUrl = mediaMetadata.feedUrl || null
this.imageUrl = mediaMetadata.imageUrl || null
this.itunesPageUrl = mediaMetadata.itunesPageUrl || null
this.itunesId = mediaMetadata.itunesId || null
this.itunesArtistId = mediaMetadata.itunesArtistId || null
this.explicit = !!mediaMetadata.explicit
this.language = mediaMetadata.language || null
this.type = mediaMetadata.type || null
if (mediaMetadata.genres && mediaMetadata.genres.length) {
this.genres = [...mediaMetadata.genres]
}
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[PodcastMetadata] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = PodcastMetadata

View file

@ -24,6 +24,7 @@ class ServerSettings {
// Security/Rate limits
this.rateLimitLoginRequests = 10
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
this.allowIframe = false
// Backups
this.backupPath = Path.join(global.MetadataPath, 'backups')
@ -99,6 +100,7 @@ class ServerSettings {
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
this.allowIframe = !!settings.allowIframe
this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
this.backupSchedule = settings.backupSchedule || false
@ -190,6 +192,11 @@ class ServerSettings {
Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`)
this.backupPath = process.env.BACKUP_PATH
}
if (process.env.ALLOW_IFRAME === '1' && !this.allowIframe) {
Logger.info(`[ServerSettings] Using allowIframe from environment variable`)
this.allowIframe = true
}
}
toJSON() {
@ -207,6 +214,7 @@ class ServerSettings {
metadataFileFormat: this.metadataFileFormat,
rateLimitLoginRequests: this.rateLimitLoginRequests,
rateLimitLoginWindow: this.rateLimitLoginWindow,
allowIframe: this.allowIframe,
backupPath: this.backupPath,
backupSchedule: this.backupSchedule,
backupsToKeep: this.backupsToKeep,

View file

@ -10,6 +10,7 @@ const fs = require('../libs/fsExtra')
const date = require('../libs/dateAndTime')
const CacheManager = require('../managers/CacheManager')
const RssFeedManager = require('../managers/RssFeedManager')
const LibraryController = require('../controllers/LibraryController')
const UserController = require('../controllers/UserController')
@ -49,8 +50,6 @@ class ApiRouter {
this.podcastManager = Server.podcastManager
/** @type {import('../managers/AudioMetadataManager')} */
this.audioMetadataManager = Server.audioMetadataManager
/** @type {import('../managers/RssFeedManager')} */
this.rssFeedManager = Server.rssFeedManager
/** @type {import('../managers/CronManager')} */
this.cronManager = Server.cronManager
/** @type {import('../managers/EmailManager')} */
@ -66,7 +65,7 @@ class ApiRouter {
//
// Library Routes
//
this.router.get(/^\/libraries/, this.apiCacheManager.middleware)
this.router.get(/^\/libraries/i, this.apiCacheManager.middleware)
this.router.post('/libraries', LibraryController.create.bind(this))
this.router.get('/libraries', LibraryController.findAll.bind(this))
this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this))
@ -106,7 +105,6 @@ class ApiRouter {
this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this))
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
@ -362,39 +360,10 @@ class ApiRouter {
}
// remove item from playlists
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
for (const playlist of playlistsWithItem) {
let numMediaItems = playlist.playlistMediaItems.length
let order = 1
// Remove items in playlist and re-order
for (const playlistMediaItem of playlist.playlistMediaItems) {
if (mediaItemIds.includes(playlistMediaItem.mediaItemId)) {
await playlistMediaItem.destroy()
numMediaItems--
} else {
if (playlistMediaItem.order !== order) {
playlistMediaItem.update({
order
})
}
order++
}
}
// If playlist is now empty then remove it
const jsonExpanded = await playlist.getOldJsonExpanded()
if (!numMediaItems) {
Logger.info(`[ApiRouter] Playlist "${playlist.name}" has no more items - removing it`)
await playlist.destroy()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
} else {
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
}
await Database.playlistModel.removeMediaItemsFromPlaylists(mediaItemIds)
// Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(libraryItemId)
await RssFeedManager.closeFeedForEntityId(libraryItemId)
// purge cover cache
await CacheManager.purgeCoverCache(libraryItemId)
@ -493,7 +462,7 @@ class ApiRouter {
* @param {import('../models/Series')} series
*/
async removeEmptySeries(series) {
await this.rssFeedManager.closeFeedForEntityId(series.id)
await RssFeedManager.closeFeedForEntityId(series.id)
Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`)
// Remove series from library filter data
@ -561,109 +530,5 @@ class ApiRouter {
})
return listeningStats
}
async createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryId) {
if (mediaPayload.metadata) {
const mediaMetadata = mediaPayload.metadata
// Create new authors if in payload
if (mediaMetadata.authors?.length) {
const newAuthors = []
for (let i = 0; i < mediaMetadata.authors.length; i++) {
const authorName = (mediaMetadata.authors[i].name || '').trim()
if (!authorName) {
Logger.error(`[ApiRouter] Invalid author object, no name`, mediaMetadata.authors[i])
mediaMetadata.authors[i].id = null
continue
}
if (mediaMetadata.authors[i].id?.startsWith('new')) {
mediaMetadata.authors[i].id = null
}
// 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) {
let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryId)
if (!author) {
author = await Database.authorModel.create({
name: authorName,
lastFirst: Database.authorModel.getLastFirst(authorName),
libraryId
})
Logger.debug(`[ApiRouter] Creating new author "${author.name}"`)
newAuthors.push(author)
// Update filter data
Database.addAuthorToFilterData(libraryId, author.name, author.id)
}
// Update ID in original payload
mediaMetadata.authors[i].id = author.id
}
}
// Remove authors without an id
mediaMetadata.authors = mediaMetadata.authors.filter((au) => !!au.id)
if (newAuthors.length) {
SocketAuthority.emitter(
'authors_added',
newAuthors.map((au) => au.toOldJSON())
)
}
}
// Create new series if in payload
if (mediaMetadata.series && mediaMetadata.series.length) {
const newSeries = []
for (let i = 0; i < mediaMetadata.series.length; i++) {
const seriesName = (mediaMetadata.series[i].name || '').trim()
if (!seriesName) {
Logger.error(`[ApiRouter] Invalid series object, no name`, mediaMetadata.series[i])
mediaMetadata.series[i].id = null
continue
}
if (mediaMetadata.series[i].id?.startsWith('new')) {
mediaMetadata.series[i].id = null
}
// 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) {
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesName, libraryId)
if (!seriesItem) {
seriesItem = await Database.seriesModel.create({
name: seriesName,
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
libraryId
})
Logger.debug(`[ApiRouter] Creating new series "${seriesItem.name}"`)
newSeries.push(seriesItem)
// Update filter data
Database.addSeriesToFilterData(libraryId, seriesItem.name, seriesItem.id)
}
// Update ID in original payload
mediaMetadata.series[i].id = seriesItem.id
}
}
// Remove series without an id
mediaMetadata.series = mediaMetadata.series.filter((se) => se.id)
if (newSeries.length) {
SocketAuthority.emitter(
'multiple_series_added',
newSeries.map((se) => se.toOldJSON())
)
}
}
}
}
}
module.exports = ApiRouter

View file

@ -15,6 +15,7 @@ class PublicRouter {
this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this))
this.router.get('/share/:slug/track/:index', ShareController.getMediaItemShareAudioTrack.bind(this))
this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this))
this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this))
this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this))
}
}

View file

@ -202,12 +202,12 @@ class AudioFileScanner {
/**
*
* @param {AudioFile} audioFile
* @param {string} audioFilePath
* @returns {object}
*/
probeAudioFile(audioFile) {
Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at "${audioFile.metadata.path}"`)
return prober.rawProbe(audioFile.metadata.path)
probeAudioFile(audioFilePath) {
Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at "${audioFilePath}"`)
return prober.rawProbe(audioFilePath)
}
/**

View file

@ -6,21 +6,24 @@ const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')
const parseNameString = require('../utils/parsers/parseNameString')
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const globals = require('../utils/globals')
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const AudioFileScanner = require('./AudioFileScanner')
const Database = require('../Database')
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const AudioFile = require('../objects/files/AudioFile')
const CoverManager = require('../managers/CoverManager')
const LibraryFile = require('../objects/files/LibraryFile')
const SocketAuthority = require('../SocketAuthority')
const fsExtra = require('../libs/fsExtra')
const BookFinder = require('../finders/BookFinder')
const fsExtra = require('../libs/fsExtra')
const EBookFile = require('../objects/files/EBookFile')
const AudioFile = require('../objects/files/AudioFile')
const LibraryFile = require('../objects/files/LibraryFile')
const RssFeedManager = require('../managers/RssFeedManager')
const CoverManager = require('../managers/CoverManager')
const LibraryScan = require('./LibraryScan')
const OpfFileScanner = require('./OpfFileScanner')
const NfoFileScanner = require('./NfoFileScanner')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
const EBookFile = require('../objects/files/EBookFile')
/**
* Metadata for books pulled from files
@ -941,6 +944,9 @@ class BookScanner {
id: bookSeriesToRemove
}
})
// Close any open feeds for series
await RssFeedManager.closeFeedsForEntityIds(bookSeriesToRemove)
bookSeriesToRemove.forEach((seriesId) => {
Database.removeSeriesFromFilterData(libraryId, seriesId)
SocketAuthority.emitter('series_removed', { id: seriesId, libraryId })

View file

@ -64,8 +64,7 @@ class LibraryItemScanner {
const { libraryItem: expandedLibraryItem, wasUpdated } = await this.rescanLibraryItemMedia(libraryItem, libraryItemScanData, library.settings, scanLogger)
if (libraryItemDataUpdated || wasUpdated) {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(expandedLibraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', expandedLibraryItem.toOldJSONExpanded())
await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger)

View file

@ -167,7 +167,7 @@ class LibraryScanner {
if (this.shouldCancelScan(libraryScan)) return true
const libraryItemIdsMissing = []
let oldLibraryItemsUpdated = []
let libraryItemsUpdated = []
for (const existingLibraryItem of existingLibraryItems) {
// First try to find matching library item with exact file path
let libraryItemData = libraryItemDataFound.find((lid) => lid.path === existingLibraryItem.path)
@ -190,11 +190,11 @@ class LibraryScanner {
libraryItemIdsMissing.push(existingLibraryItem.id)
// TODO: Temporary while using old model to socket emit
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id)
if (oldLibraryItem) {
oldLibraryItem.isMissing = true
oldLibraryItem.updatedAt = Date.now()
oldLibraryItemsUpdated.push(oldLibraryItem)
const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id)
if (libraryItem) {
libraryItem.isMissing = true
await libraryItem.save()
libraryItemsUpdated.push(libraryItem)
}
}
}
@ -206,16 +206,15 @@ class LibraryScanner {
const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)
if (!forceRescan || wasUpdated) {
libraryScan.resultsUpdated++
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
oldLibraryItemsUpdated.push(oldLibraryItem)
libraryItemsUpdated.push(libraryItem)
} else {
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
}
} else {
libraryScan.resultsUpdated++
// TODO: Temporary while using old model to socket emit
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id)
oldLibraryItemsUpdated.push(oldLibraryItem)
const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id)
libraryItemsUpdated.push(libraryItem)
}
} else {
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
@ -223,23 +222,23 @@ class LibraryScanner {
}
// Emit item updates in chunks of 10 to client
if (oldLibraryItemsUpdated.length === 10) {
if (libraryItemsUpdated.length === 10) {
// TODO: Should only emit to clients where library item is accessible
SocketAuthority.emitter(
'items_updated',
oldLibraryItemsUpdated.map((li) => li.toJSONExpanded())
libraryItemsUpdated.map((li) => li.toOldJSONExpanded())
)
oldLibraryItemsUpdated = []
libraryItemsUpdated = []
}
if (this.shouldCancelScan(libraryScan)) return true
}
// Emit item updates to client
if (oldLibraryItemsUpdated.length) {
if (libraryItemsUpdated.length) {
// TODO: Should only emit to clients where library item is accessible
SocketAuthority.emitter(
'items_updated',
oldLibraryItemsUpdated.map((li) => li.toJSONExpanded())
libraryItemsUpdated.map((li) => li.toOldJSONExpanded())
)
}
@ -267,34 +266,33 @@ class LibraryScanner {
// Add new library items
if (libraryItemDataFound.length) {
let newOldLibraryItems = []
let newLibraryItems = []
for (const libraryItemData of libraryItemDataFound) {
const newLibraryItem = await LibraryItemScanner.scanNewLibraryItem(libraryItemData, libraryScan.library.settings, libraryScan)
if (newLibraryItem) {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem)
newOldLibraryItems.push(oldLibraryItem)
newLibraryItems.push(newLibraryItem)
libraryScan.resultsAdded++
}
// Emit new items in chunks of 10 to client
if (newOldLibraryItems.length === 10) {
if (newLibraryItems.length === 10) {
// TODO: Should only emit to clients where library item is accessible
SocketAuthority.emitter(
'items_added',
newOldLibraryItems.map((li) => li.toJSONExpanded())
newLibraryItems.map((li) => li.toOldJSONExpanded())
)
newOldLibraryItems = []
newLibraryItems = []
}
if (this.shouldCancelScan(libraryScan)) return true
}
// Emit new items to client
if (newOldLibraryItems.length) {
if (newLibraryItems.length) {
// TODO: Should only emit to clients where library item is accessible
SocketAuthority.emitter(
'items_added',
newOldLibraryItems.map((li) => li.toJSONExpanded())
newLibraryItems.map((li) => li.toOldJSONExpanded())
)
}
}
@ -584,7 +582,7 @@ class LibraryScanner {
}
// Check if book dir group is already an item
let existingLibraryItem = await Database.libraryItemModel.findOneOld({
let existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
libraryId: library.id,
path: potentialChildDirs
})
@ -608,17 +606,17 @@ class LibraryScanner {
if (existingLibraryItem.path === fullPath) {
const exists = await fs.pathExists(fullPath)
if (!exists) {
Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`)
existingLibraryItem.setMissing()
await Database.updateLibraryItem(existingLibraryItem)
SocketAuthority.emitter('item_updated', existingLibraryItem.toJSONExpanded())
Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.title}" - marking as missing`)
existingLibraryItem.isMissing = true
await existingLibraryItem.save()
SocketAuthority.emitter('item_updated', existingLibraryItem.toOldJSONExpanded())
itemGroupingResults[itemDir] = ScanResult.REMOVED
continue
}
}
// Scan library item for updates
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" with id "${existingLibraryItem.id}" - scan for updates`)
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.title}" with id "${existingLibraryItem.id}" - scan for updates`)
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails)
continue
} else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {
@ -645,8 +643,7 @@ class LibraryScanner {
const isSingleMediaItem = isSingleMediaFile(fileUpdateGroup, itemDir)
const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem)
if (newLibraryItem) {
const oldNewLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem)
SocketAuthority.emitter('item_added', oldNewLibraryItem.toJSONExpanded())
SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
}
itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING
}
@ -675,7 +672,7 @@ function isSingleMediaFile(fileUpdateGroup, itemDir) {
async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) {
const ino = await fileUtils.getIno(fullPath)
if (!ino) return null
const existingLibraryItem = await Database.libraryItemModel.findOneOld({
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
libraryId: libraryId,
ino: ino
})
@ -688,7 +685,7 @@ async function findLibraryItemByItemToFileInoMatch(libraryId, fullPath, isSingle
// check if it was moved from another folder by comparing the ino to the library files
const ino = await fileUtils.getIno(fullPath)
if (!ino) return null
const existingLibraryItem = await Database.libraryItemModel.findOneOld(
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded(
[
{
libraryId: libraryId
@ -714,7 +711,7 @@ async function findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingle
if (ino) itemFileInos.push(ino)
}
if (!itemFileInos.length) return null
const existingLibraryItem = await Database.libraryItemModel.findOneOld({
const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
libraryId: libraryId,
ino: {
[sequelize.Op.in]: itemFileInos

View file

@ -13,66 +13,96 @@ const LibraryScanner = require('./LibraryScanner')
const CoverManager = require('../managers/CoverManager')
const TaskManager = require('../managers/TaskManager')
/**
* @typedef QuickMatchOptions
* @property {string} [provider]
* @property {string} [title]
* @property {string} [author]
* @property {string} [isbn] - This override is currently unused in Abs clients
* @property {string} [asin] - This override is currently unused in Abs clients
* @property {boolean} [overrideCover]
* @property {boolean} [overrideDetails]
*/
class Scanner {
constructor() {}
async quickMatchLibraryItem(libraryItem, options = {}) {
var provider = options.provider || 'google'
var searchTitle = options.title || libraryItem.media.metadata.title
var searchAuthor = options.author || libraryItem.media.metadata.authorName
var overrideDefaults = options.overrideDefaults || false
/**
*
* @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../models/LibraryItem')} libraryItem
* @param {QuickMatchOptions} options
* @returns {Promise<{updated: boolean, libraryItem: Object}>}
*/
async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) {
const provider = options.provider || 'google'
const searchTitle = options.title || libraryItem.media.title
const searchAuthor = options.author || libraryItem.media.authorName
// Set to override existing metadata if scannerPreferMatchedMetadata setting is true and
// the overrideDefaults option is not set or set to false.
if (overrideDefaults == false && Database.serverSettings.scannerPreferMatchedMetadata) {
// If overrideCover and overrideDetails is not sent in options than use the server setting to determine if we should override
if (options.overrideCover === undefined && options.overrideDetails === undefined && Database.serverSettings.scannerPreferMatchedMetadata) {
options.overrideCover = true
options.overrideDetails = true
}
var updatePayload = {}
var hasUpdated = false
let updatePayload = {}
let hasUpdated = false
let existingAuthors = [] // Used for checking if authors or series are now empty
let existingSeries = []
if (libraryItem.isBook) {
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
var searchASIN = options.asin || libraryItem.media.metadata.asin
existingAuthors = libraryItem.media.authors.map((a) => a.id)
existingSeries = libraryItem.media.series.map((s) => s.id)
var results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
const searchISBN = options.isbn || libraryItem.media.isbn
const searchASIN = options.asin || libraryItem.media.asin
const results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
if (!results.length) {
return {
warning: `No ${provider} match found`
}
}
var matchData = results[0]
const matchData = results[0]
// Update cover if not set OR overrideCover flag
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
var coverResult = await CoverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
if (!coverResult || coverResult.error || !coverResult.cover) {
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
const coverResult = await CoverManager.downloadCoverFromUrlNew(matchData.cover, libraryItem.id, libraryItem.isFile ? null : libraryItem.path)
if (coverResult.error) {
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult.error}`)
} else {
libraryItem.media.coverPath = coverResult.cover
libraryItem.media.changed('coverPath', true) // Cover path may be the same but this forces the update
hasUpdated = true
}
}
updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options)
const bookBuildUpdateData = await this.quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options)
updatePayload = bookBuildUpdateData.updatePayload
if (bookBuildUpdateData.hasSeriesUpdates || bookBuildUpdateData.hasAuthorUpdates) {
hasUpdated = true
}
} else if (libraryItem.isPodcast) {
// Podcast quick match
var results = await PodcastFinder.search(searchTitle)
const results = await PodcastFinder.search(searchTitle)
if (!results.length) {
return {
warning: `No ${provider} match found`
}
}
var matchData = results[0]
const matchData = results[0]
// Update cover if not set OR overrideCover flag
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
var coverResult = await CoverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
if (!coverResult || coverResult.error || !coverResult.cover) {
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
const coverResult = await CoverManager.downloadCoverFromUrlNew(matchData.cover, libraryItem.id, libraryItem.path)
if (coverResult.error) {
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult.error}`)
} else {
libraryItem.media.coverPath = coverResult.cover
libraryItem.media.changed('coverPath', true) // Cover path may be the same but this forces the update
hasUpdated = true
}
}
@ -81,31 +111,45 @@ class Scanner {
}
if (Object.keys(updatePayload).length) {
Logger.debug('[Scanner] Updating details', updatePayload)
if (libraryItem.media.update(updatePayload)) {
Logger.debug('[Scanner] Updating details with payload', updatePayload)
libraryItem.media.set(updatePayload)
if (libraryItem.media.changed()) {
Logger.debug(`[Scanner] Updating library item "${libraryItem.media.title}" keys`, libraryItem.media.changed())
hasUpdated = true
}
}
if (hasUpdated) {
if (libraryItem.isPodcast && libraryItem.media.metadata.feedUrl) {
if (libraryItem.isPodcast && libraryItem.media.feedURL) {
// Quick match all unmatched podcast episodes
await this.quickMatchPodcastEpisodes(libraryItem, options)
}
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
await libraryItem.media.save()
libraryItem.changed('updatedAt', true)
await libraryItem.save()
await libraryItem.saveMetadataFile()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
}
return {
updated: hasUpdated,
libraryItem: libraryItem.toJSONExpanded()
libraryItem: libraryItem.toOldJSONExpanded()
}
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} matchData
* @param {QuickMatchOptions} options
* @returns {Map<string, any>} - Update payload
*/
quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options) {
const updatePayload = {}
updatePayload.metadata = {}
const matchDataTransformed = {
title: matchData.title || null,
@ -123,7 +167,7 @@ class Scanner {
for (const key in matchDataTransformed) {
if (matchDataTransformed[key]) {
if (key === 'genres') {
if (!libraryItem.media.metadata.genres.length || options.overrideDetails) {
if (!libraryItem.media.genres.length || options.overrideDetails) {
var genresArray = []
if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]]
else {
@ -134,39 +178,42 @@ class Scanner {
.map((v) => v.trim())
.filter((v) => !!v)
}
updatePayload.metadata[key] = genresArray
updatePayload[key] = genresArray
}
} else if (libraryItem.media.metadata[key] !== matchDataTransformed[key] && (!libraryItem.media.metadata[key] || options.overrideDetails)) {
updatePayload.metadata[key] = matchDataTransformed[key]
} else if (libraryItem.media[key] !== matchDataTransformed[key] && (!libraryItem.media[key] || options.overrideDetails)) {
updatePayload[key] = matchDataTransformed[key]
}
}
}
if (!Object.keys(updatePayload.metadata).length) {
delete updatePayload.metadata
}
return updatePayload
}
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
/**
*
* @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} matchData
* @param {QuickMatchOptions} options
* @returns {Promise<{updatePayload: Map<string, any>, seriesIdsRemoved: string[], hasSeriesUpdates: boolean, authorIdsRemoved: string[], hasAuthorUpdates: boolean}>}
*/
async quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options) {
// Update media metadata if not set OR overrideDetails flag
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']
const updatePayload = {}
updatePayload.metadata = {}
for (const key in matchData) {
if (matchData[key] && detailKeysToUpdate.includes(key)) {
if (key === 'narrator') {
if (!libraryItem.media.metadata.narratorName || options.overrideDetails) {
updatePayload.metadata.narrators = matchData[key]
if (!libraryItem.media.narrators?.length || options.overrideDetails) {
updatePayload.narrators = matchData[key]
.split(',')
.map((v) => v.trim())
.filter((v) => !!v)
}
} else if (key === 'genres') {
if (!libraryItem.media.metadata.genres.length || options.overrideDetails) {
var genresArray = []
if (!libraryItem.media.genres.length || options.overrideDetails) {
let genresArray = []
if (Array.isArray(matchData[key])) genresArray = [...matchData[key]]
else {
// Genres should always be passed in as an array but just incase handle a string
@ -176,11 +223,11 @@ class Scanner {
.map((v) => v.trim())
.filter((v) => !!v)
}
updatePayload.metadata[key] = genresArray
updatePayload[key] = genresArray
}
} else if (key === 'tags') {
if (!libraryItem.media.tags.length || options.overrideDetails) {
var tagsArray = []
let tagsArray = []
if (Array.isArray(matchData[key])) tagsArray = [...matchData[key]]
else
tagsArray = matchData[key]
@ -189,94 +236,174 @@ class Scanner {
.filter((v) => !!v)
updatePayload[key] = tagsArray
}
} else if (!libraryItem.media.metadata[key] || options.overrideDetails) {
updatePayload.metadata[key] = matchData[key]
} else if (!libraryItem.media[key] || options.overrideDetails) {
updatePayload[key] = matchData[key]
}
}
}
// Add or set author if not set
if (matchData.author && (!libraryItem.media.metadata.authorName || options.overrideDetails)) {
let hasAuthorUpdates = false
if (matchData.author && (!libraryItem.media.authorName || options.overrideDetails)) {
if (!Array.isArray(matchData.author)) {
matchData.author = matchData.author
.split(',')
.map((au) => au.trim())
.filter((au) => !!au)
}
const authorPayload = []
const authorIdsRemoved = []
for (const authorName of matchData.author) {
let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId)
if (!author) {
author = await Database.authorModel.create({
name: authorName,
lastFirst: Database.authorModel.getLastFirst(authorName),
libraryId: libraryItem.libraryId
})
SocketAuthority.emitter('author_added', author.toOldJSON())
// Update filter data
Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id)
const existingAuthor = libraryItem.media.authors.find((a) => a.name.toLowerCase() === authorName.toLowerCase())
if (!existingAuthor) {
let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId)
if (!author) {
author = await Database.authorModel.create({
name: authorName,
lastFirst: Database.authorModel.getLastFirst(authorName),
libraryId: libraryItem.libraryId
})
SocketAuthority.emitter('author_added', author.toOldJSON())
// Update filter data
Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id)
await Database.bookAuthorModel
.create({
authorId: author.id,
bookId: libraryItem.media.id
})
.then(() => {
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added author "${author.name}" to "${libraryItem.media.title}"`)
libraryItem.media.authors.push(author)
hasAuthorUpdates = true
})
}
}
const authorsRemoved = libraryItem.media.authors.filter((a) => !matchData.author.find((ma) => ma.toLowerCase() === a.name.toLowerCase()))
if (authorsRemoved.length) {
for (const author of authorsRemoved) {
await Database.bookAuthorModel.destroy({ where: { authorId: author.id, bookId: libraryItem.media.id } })
libraryItem.media.authors = libraryItem.media.authors.filter((a) => a.id !== author.id)
authorIdsRemoved.push(author.id)
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Removed author "${author.name}" from "${libraryItem.media.title}"`)
}
hasAuthorUpdates = true
}
authorPayload.push(author.toJSONMinimal())
}
updatePayload.metadata.authors = authorPayload
// For all authors removed from book, check if they are empty now and should be removed
if (authorIdsRemoved.length) {
await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
}
}
// Add or set series if not set
if (matchData.series && (!libraryItem.media.metadata.seriesName || options.overrideDetails)) {
let hasSeriesUpdates = false
if (matchData.series && (!libraryItem.media.seriesName || options.overrideDetails)) {
if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, sequence: matchData.sequence }]
const seriesPayload = []
const seriesIdsRemoved = []
for (const seriesMatchItem of matchData.series) {
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId)
if (!seriesItem) {
seriesItem = await Database.seriesModel.create({
name: seriesMatchItem.series,
nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series),
libraryId: libraryItem.libraryId
const existingSeries = libraryItem.media.series.find((s) => s.name.toLowerCase() === seriesMatchItem.series.toLowerCase())
if (existingSeries) {
if (existingSeries.bookSeries.sequence !== seriesMatchItem.sequence) {
existingSeries.bookSeries.sequence = seriesMatchItem.sequence
await existingSeries.bookSeries.save()
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Updated series sequence for "${existingSeries.name}" to ${seriesMatchItem.sequence} in "${libraryItem.media.title}"`)
hasSeriesUpdates = true
}
} else {
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId)
if (!seriesItem) {
seriesItem = await Database.seriesModel.create({
name: seriesMatchItem.series,
nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series),
libraryId: libraryItem.libraryId
})
// Update filter data
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
SocketAuthority.emitter('series_added', seriesItem.toOldJSON())
}
const bookSeries = await Database.bookSeriesModel.create({
seriesId: seriesItem.id,
bookId: libraryItem.media.id,
sequence: seriesMatchItem.sequence
})
// Update filter data
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
SocketAuthority.emitter('series_added', seriesItem.toOldJSON())
seriesItem.bookSeries = bookSeries
libraryItem.media.series.push(seriesItem)
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added series "${seriesItem.name}" to "${libraryItem.media.title}"`)
hasSeriesUpdates = true
}
const seriesRemoved = libraryItem.media.series.filter((s) => !matchData.series.find((ms) => ms.series.toLowerCase() === s.name.toLowerCase()))
if (seriesRemoved.length) {
for (const series of seriesRemoved) {
await series.bookSeries.destroy()
libraryItem.media.series = libraryItem.media.series.filter((s) => s.id !== series.id)
seriesIdsRemoved.push(series.id)
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Removed series "${series.name}" from "${libraryItem.media.title}"`)
}
hasSeriesUpdates = true
}
seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence))
}
updatePayload.metadata.series = seriesPayload
// For all series removed from book, check if it is empty now and should be removed
if (seriesIdsRemoved.length) {
await apiRouterCtx.checkRemoveEmptySeries(seriesIdsRemoved)
}
}
if (!Object.keys(updatePayload.metadata).length) {
delete updatePayload.metadata
return {
updatePayload,
hasSeriesUpdates,
hasAuthorUpdates
}
return updatePayload
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {QuickMatchOptions} options
* @returns {Promise<number>} - Number of episodes updated
*/
async quickMatchPodcastEpisodes(libraryItem, options = {}) {
const episodesToQuickMatch = libraryItem.media.episodes.filter((ep) => !ep.enclosureUrl) // Only quick match episodes without enclosure
if (!episodesToQuickMatch.length) return false
/** @type {import('../models/PodcastEpisode')[]} */
const episodesToQuickMatch = libraryItem.media.podcastEpisodes.filter((ep) => !ep.enclosureURL) // Only quick match episodes that are not already matched
if (!episodesToQuickMatch.length) return 0
const feed = await getPodcastFeed(libraryItem.media.metadata.feedUrl)
const feed = await getPodcastFeed(libraryItem.media.feedURL)
if (!feed) {
Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for "${libraryItem.media.metadata.feedUrl}"`)
return false
Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for "${libraryItem.media.feedURL}"`)
return 0
}
let numEpisodesUpdated = 0
for (const episode of episodesToQuickMatch) {
const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title)
if (episodeMatches && episodeMatches.length) {
const wasUpdated = this.updateEpisodeWithMatch(libraryItem, episode, episodeMatches[0].episode, options)
if (episodeMatches?.length) {
const wasUpdated = await this.updateEpisodeWithMatch(episode, episodeMatches[0].episode, options)
if (wasUpdated) numEpisodesUpdated++
}
}
if (numEpisodesUpdated) {
Logger.info(`[Scanner] quickMatchPodcastEpisodes: Updated ${numEpisodesUpdated} episodes for "${libraryItem.media.title}"`)
}
return numEpisodesUpdated
}
updateEpisodeWithMatch(libraryItem, episode, episodeToMatch, options = {}) {
/**
*
* @param {import('../models/PodcastEpisode')} episode
* @param {import('../utils/podcastUtils').RssPodcastEpisode} episodeToMatch
* @param {QuickMatchOptions} options
* @returns {Promise<boolean>} - true if episode was updated
*/
async updateEpisodeWithMatch(episode, episodeToMatch, options = {}) {
Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Found episode match for "${episode.title}" => ${episodeToMatch.title}`)
const matchDataTransformed = {
title: episodeToMatch.title || '',
subtitle: episodeToMatch.subtitle || '',
description: episodeToMatch.description || '',
enclosure: episodeToMatch.enclosure || null,
enclosureURL: episodeToMatch.enclosure?.url || null,
enclosureSize: episodeToMatch.enclosure?.length || null,
enclosureType: episodeToMatch.enclosure?.type || null,
episode: episodeToMatch.episode || '',
episodeType: episodeToMatch.episodeType || 'full',
season: episodeToMatch.season || '',
@ -286,20 +413,19 @@ class Scanner {
const updatePayload = {}
for (const key in matchDataTransformed) {
if (matchDataTransformed[key]) {
if (key === 'enclosure') {
if (!episode.enclosure || JSON.stringify(episode.enclosure) !== JSON.stringify(matchDataTransformed.enclosure)) {
updatePayload[key] = {
...matchDataTransformed.enclosure
}
}
} else if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) {
if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) {
updatePayload[key] = matchDataTransformed[key]
}
}
}
if (Object.keys(updatePayload).length) {
return libraryItem.media.updateEpisode(episode.id, updatePayload)
episode.set(updatePayload)
if (episode.changed()) {
Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Updating episode "${episode.title}" keys`, episode.changed())
await episode.save()
return true
}
}
return false
}
@ -307,29 +433,30 @@ class Scanner {
/**
* Quick match library items
*
* @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../models/Library')} library
* @param {import('../objects/LibraryItem')[]} libraryItems
* @param {import('../models/LibraryItem')[]} libraryItems
* @param {LibraryScan} libraryScan
* @returns {Promise<boolean>} false if scan canceled
*/
async matchLibraryItemsChunk(library, libraryItems, libraryScan) {
async matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan) {
for (let i = 0; i < libraryItems.length; i++) {
const libraryItem = libraryItems[i]
if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) {
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title}" because it already has an ASIN (${i + 1} of ${libraryItems.length})`)
if (libraryItem.media.asin && library.settings.skipMatchingMediaWithAsin) {
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.title}" because it already has an ASIN (${i + 1} of ${libraryItems.length})`)
continue
}
if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) {
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title}" because it already has an ISBN (${i + 1} of ${libraryItems.length})`)
if (libraryItem.media.isbn && library.settings.skipMatchingMediaWithIsbn) {
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.title}" because it already has an ISBN (${i + 1} of ${libraryItems.length})`)
continue
}
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${libraryItems.length})`)
const result = await this.quickMatchLibraryItem(libraryItem, { provider: library.provider })
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.title}" (${i + 1} of ${libraryItems.length})`)
const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider })
if (result.warning) {
Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.metadata.title}"`)
Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.title}"`)
} else if (result.updated) {
libraryScan.resultsUpdated++
}
@ -346,9 +473,10 @@ class Scanner {
/**
* Quick match all library items for library
*
* @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../models/Library')} library
*/
async matchLibraryItems(library) {
async matchLibraryItems(apiRouterCtx, library) {
if (library.mediaType === 'podcast') {
Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`)
return
@ -386,9 +514,8 @@ class Scanner {
offset += limit
hasMoreChunks = libraryItems.length === limit
let oldLibraryItems = libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li))
const shouldContinue = await this.matchLibraryItemsChunk(library, oldLibraryItems, libraryScan)
const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan)
if (!shouldContinue) {
isCanceled = true
break

View file

@ -5,11 +5,10 @@ const fs = require('../libs/fsExtra')
const Path = require('path')
const Logger = require('../Logger')
const { filePathToPOSIX, copyToExisting } = require('./fileUtils')
const LibraryItem = require('../objects/LibraryItem')
function escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'')
return filePathToPOSIX(path).replace(/ /g, '\\ ').replace(/'/g, "\\'")
// A ' within a quoted string is escaped with '\'' in ffmpeg (see https://www.ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping)
return filePathToPOSIX(path).replace(/'/g, "'\\''")
}
// Returns first track start time
@ -33,7 +32,7 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) {
var tracksToInclude = tracks.filter((t) => t.index >= trackToStartWithIndex)
var trackPaths = tracksToInclude.map((t) => {
var line = 'file ' + escapeSingleQuotes(t.metadata.path) + '\n' + `duration ${t.duration}`
var line = "file '" + escapeSingleQuotes(t.metadata.path) + "'\n" + `duration ${t.duration}`
return line
})
var inputstr = trackPaths.join('\n\n')
@ -97,6 +96,11 @@ async function resizeImage(filePath, outputPath, width, height) {
}
module.exports.resizeImage = resizeImage
/**
*
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
* @returns
*/
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
return new Promise(async (resolve) => {
const response = await axios({
@ -118,32 +122,33 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
ffmpeg.addOption('-loglevel debug') // Debug logs printed on error
ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1')
const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata
const podcastEpisode = podcastEpisodeDownload.podcastEpisode
/** @type {import('../models/Podcast')} */
const podcast = podcastEpisodeDownload.libraryItem.media
const podcastEpisode = podcastEpisodeDownload.rssPodcastEpisode
const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0)
const taggings = {
album: podcastMetadata.title,
'album-sort': podcastMetadata.title,
artist: podcastMetadata.author,
'artist-sort': podcastMetadata.author,
album: podcast.title,
'album-sort': podcast.title,
artist: podcast.author,
'artist-sort': podcast.author,
comment: podcastEpisode.description,
subtitle: podcastEpisode.subtitle,
disc: podcastEpisode.season,
genre: podcastMetadata.genres.length ? podcastMetadata.genres.join(';') : null,
language: podcastMetadata.language,
MVNM: podcastMetadata.title,
genre: podcast.genres.length ? podcast.genres.join(';') : null,
language: podcast.language,
MVNM: podcast.title,
MVIN: podcastEpisode.episode,
track: podcastEpisode.episode,
'series-part': podcastEpisode.episode,
title: podcastEpisode.title,
'title-sort': podcastEpisode.title,
year: podcastEpisode.pubYear,
year: podcastEpisodeDownload.pubYear,
date: podcastEpisode.pubDate,
releasedate: podcastEpisode.pubDate,
'itunes-id': podcastMetadata.itunesId,
'podcast-type': podcastMetadata.type,
'episode-type': podcastMetadata.episodeType
'itunes-id': podcast.itunesId,
'podcast-type': podcast.podcastType,
'episode-type': podcastEpisode.episodeType
}
for (const tag in taggings) {
@ -359,28 +364,26 @@ function escapeFFMetadataValue(value) {
/**
* Retrieves the FFmpeg metadata object for a given library item.
*
* @param {LibraryItem} libraryItem - The library item containing the media metadata.
* @param {import('../models/LibraryItem')} libraryItem - The library item containing the media metadata.
* @param {number} audioFilesLength - The length of the audio files.
* @returns {Object} - The FFmpeg metadata object.
*/
function getFFMetadataObject(libraryItem, audioFilesLength) {
const metadata = libraryItem.media.metadata
const ffmetadata = {
title: metadata.title,
artist: metadata.authorName,
album_artist: metadata.authorName,
album: (metadata.title || '') + (metadata.subtitle ? `: ${metadata.subtitle}` : ''),
TIT3: metadata.subtitle, // mp3 only
genre: metadata.genres?.join('; '),
date: metadata.publishedYear,
comment: metadata.description,
description: metadata.description,
composer: metadata.narratorName,
copyright: metadata.publisher,
publisher: metadata.publisher, // mp3 only
title: libraryItem.media.title,
artist: libraryItem.media.authorName,
album_artist: libraryItem.media.authorName,
album: (libraryItem.media.title || '') + (libraryItem.media.subtitle ? `: ${libraryItem.media.subtitle}` : ''),
TIT3: libraryItem.media.subtitle, // mp3 only
genre: libraryItem.media.genres?.join('; '),
date: libraryItem.media.publishedYear,
comment: libraryItem.media.description,
description: libraryItem.media.description,
composer: (libraryItem.media.narrators || []).join(', '),
copyright: libraryItem.media.publisher,
publisher: libraryItem.media.publisher, // mp3 only
TRACKTOTAL: `${audioFilesLength}`, // mp3 only
grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join('; ')
grouping: libraryItem.media.series?.map((s) => s.name + (s.bookSeries.sequence ? ` #${s.bookSeries.sequence}` : '')).join('; ')
}
Object.keys(ffmetadata).forEach((key) => {
if (!ffmetadata[key]) {
@ -396,7 +399,7 @@ module.exports.getFFMetadataObject = getFFMetadataObject
/**
* Merges audio files into a single output file using FFmpeg.
*
* @param {Array} audioTracks - The audio tracks to merge.
* @param {import('../models/Book').AudioFileObject} audioTracks - The audio tracks to merge.
* @param {number} duration - The total duration of the audio tracks.
* @param {string} itemCachePath - The path to the item cache.
* @param {string} outputFilePath - The path to the output file.

View file

@ -277,8 +277,8 @@ module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => {
'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'
},
timeout: 30000,
httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(url),
httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(url)
httpAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url),
httpsAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url)
})
.then((response) => {
// Validate content type

View file

@ -6,35 +6,41 @@ const naturalSort = createNewSortInstance({
})
module.exports = {
getSeriesFromBooks(books, filterSeries, hideSingleBookSeries) {
/**
*
* @param {import('../models/LibraryItem')[]} libraryItems
* @param {*} filterSeries
* @param {*} hideSingleBookSeries
* @returns
*/
getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries) {
const _series = {}
const seriesToFilterOut = {}
books.forEach((libraryItem) => {
libraryItems.forEach((libraryItem) => {
// get all book series for item that is not already filtered out
const bookSeries = (libraryItem.media.metadata.series || []).filter((se) => !seriesToFilterOut[se.id])
if (!bookSeries.length) return
const allBookSeries = (libraryItem.media.series || []).filter((se) => !seriesToFilterOut[se.id])
if (!allBookSeries.length) return
bookSeries.forEach((bookSeriesObj) => {
// const series = allSeries.find(se => se.id === bookSeriesObj.id)
const abJson = libraryItem.toJSONMinified()
abJson.sequence = bookSeriesObj.sequence
allBookSeries.forEach((bookSeries) => {
const abJson = libraryItem.toOldJSONMinified()
abJson.sequence = bookSeries.bookSeries.sequence
if (filterSeries) {
abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence
const series = libraryItem.media.series.find((se) => se.id === filterSeries)
abJson.filterSeriesSequence = series.bookSeries.sequence
}
if (!_series[bookSeriesObj.id]) {
_series[bookSeriesObj.id] = {
id: bookSeriesObj.id,
name: bookSeriesObj.name,
nameIgnorePrefix: getTitlePrefixAtEnd(bookSeriesObj.name),
nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name),
if (!_series[bookSeries.id]) {
_series[bookSeries.id] = {
id: bookSeries.id,
name: bookSeries.name,
nameIgnorePrefix: getTitlePrefixAtEnd(bookSeries.name),
nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeries.name),
type: 'series',
books: [abJson],
totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
}
} else {
_series[bookSeriesObj.id].books.push(abJson)
_series[bookSeriesObj.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
_series[bookSeries.id].books.push(abJson)
_series[bookSeries.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
}
})
})
@ -52,6 +58,13 @@ module.exports = {
})
},
/**
*
* @param {import('../models/LibraryItem')[]} libraryItems
* @param {string} filterSeries - series id
* @param {boolean} hideSingleBookSeries
* @returns
*/
collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) {
// Get series from the library items. If this list is being collapsed after filtering for a series,
// don't collapse that series, only books that are in other series.
@ -123,8 +136,9 @@ module.exports = {
let libraryItems = books
.map((book) => {
const libraryItem = book.libraryItem
delete book.libraryItem
libraryItem.media = book
return Database.libraryItemModel.getOldLibraryItem(libraryItem)
return libraryItem
})
.filter((li) => {
return user.checkCanAccessLibraryItem(li)
@ -143,15 +157,18 @@ module.exports = {
if (!payload.sortBy || payload.sortBy === 'sequence') {
sortArray = [
{
[direction]: (li) => li.media.metadata.getSeries(seriesId).sequence
[direction]: (li) => {
const series = li.media.series.find((se) => se.id === seriesId)
return series.bookSeries.sequence
}
},
{
// If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
[direction]: (li) => {
if (sortingIgnorePrefix) {
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix
} else {
return li.collapsedSeries?.name || li.media.metadata.title
return li.collapsedSeries?.name || li.media.title
}
}
}
@ -174,9 +191,9 @@ module.exports = {
[direction]: (li) => {
if (payload.sortBy === 'media.metadata.title') {
if (sortingIgnorePrefix) {
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix
} else {
return li.collapsedSeries?.name || li.media.metadata.title
return li.collapsedSeries?.name || li.media.title
}
} else {
return payload.sortBy.split('.').reduce((a, b) => a[b], li)
@ -194,12 +211,12 @@ module.exports = {
return Promise.all(
libraryItems.map(async (li) => {
const filteredSeries = li.media.metadata.getSeries(seriesId)
const json = li.toJSONMinified()
const filteredSeries = li.media.series.find((se) => se.id === seriesId)
const json = li.toOldJSONMinified()
json.media.metadata.series = {
id: filteredSeries.id,
name: filteredSeries.name,
sequence: filteredSeries.sequence
sequence: filteredSeries.bookSeries.sequence
}
if (li.collapsedSeries) {

View file

@ -1200,7 +1200,7 @@ async function migrationPatchNewColumns(queryInterface) {
*/
async function handleOldLibraryItems(ctx) {
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
const libraryItems = await ctx.models.libraryItem.getAllOldLibraryItems()
const libraryItems = await ctx.models.libraryItem.findAllExpandedWhere()
const bulkUpdateItems = []
const bulkUpdateEpisodes = []
@ -1218,8 +1218,8 @@ async function handleOldLibraryItems(ctx) {
}
})
if (libraryItem.media.episodes?.length && matchingOldLibraryItem.media.episodes?.length) {
for (const podcastEpisode of libraryItem.media.episodes) {
if (libraryItem.media.podcastEpisodes?.length && matchingOldLibraryItem.media.episodes?.length) {
for (const podcastEpisode of libraryItem.media.podcastEpisodes) {
// Find matching old episode by audio file ino
const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find((oep) => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino)
if (matchingOldPodcastEpisode) {

View file

@ -4,6 +4,49 @@ const Logger = require('../Logger')
const { xmlToJSON, levenshteinDistance } = require('./index')
const htmlSanitizer = require('../utils/htmlSanitizer')
/**
* @typedef RssPodcastEpisode
* @property {string} title
* @property {string} subtitle
* @property {string} description
* @property {string} descriptionPlain
* @property {string} pubDate
* @property {string} episodeType
* @property {string} season
* @property {string} episode
* @property {string} author
* @property {string} duration
* @property {string} explicit
* @property {number} publishedAt - Unix timestamp
* @property {{ url: string, type?: string, length?: string }} enclosure
* @property {string} guid
* @property {string} chaptersUrl
* @property {string} chaptersType
*/
/**
* @typedef RssPodcastMetadata
* @property {string} title
* @property {string} language
* @property {string} explicit
* @property {string} author
* @property {string} pubDate
* @property {string} link
* @property {string} image
* @property {string[]} categories
* @property {string} feedUrl
* @property {string} description
* @property {string} descriptionPlain
* @property {string} type
*/
/**
* @typedef RssPodcast
* @property {RssPodcastMetadata} metadata
* @property {RssPodcastEpisode[]} episodes
* @property {number} numEpisodes
*/
function extractFirstArrayItem(json, key) {
if (!json[key]?.length) return null
return json[key][0]
@ -59,8 +102,8 @@ function extractPodcastMetadata(channel) {
if (channel['description']) {
const rawDescription = extractFirstArrayItem(channel, 'description') || ''
metadata.description = htmlSanitizer.sanitize(rawDescription)
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
metadata.description = htmlSanitizer.sanitize(rawDescription.trim())
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
}
const arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']
@ -103,8 +146,8 @@ function extractEpisodeData(item) {
// Supposed to be the plaintext description but not always followed
if (item['description']) {
const rawDescription = extractFirstArrayItem(item, 'description') || ''
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription)
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription.trim())
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
}
if (item['pubDate']) {
@ -223,7 +266,7 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal
*
* @param {string} feedUrl
* @param {boolean} [excludeEpisodeMetadata=false]
* @returns {Promise}
* @returns {Promise<RssPodcast|null>}
*/
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
@ -244,8 +287,8 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',
'User-Agent': userAgent
},
httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl),
httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl)
httpAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl),
httpsAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl)
})
.then(async (data) => {
// Adding support for ios-8859-1 encoded RSS feeds.
@ -287,6 +330,12 @@ module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => {
return this.findMatchingEpisodesInFeed(feed, searchTitle)
}
/**
*
* @param {RssPodcast} feed
* @param {string} searchTitle
* @returns {Array<{ episode: RssPodcastEpisode, levenshtein: number }>}
*/
module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
searchTitle = searchTitle.toLowerCase().trim()
if (!feed?.episodes) {

View file

@ -18,7 +18,7 @@ module.exports = {
* @param {string} libraryId
* @param {import('../../models/User')} user
* @param {object} options
* @returns {object} { libraryItems:LibraryItem[], count:number }
* @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}
*/
async getFilteredLibraryItems(libraryId, user, options) {
const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = options
@ -52,9 +52,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'in-progress', 'progress', true, false, include, limit, 0, true)
return {
items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare
@ -68,7 +68,7 @@ module.exports = {
return {
count,
items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = li.toOldJSONMinified()
oldLibraryItem.recentEpisode = li.recentEpisode
return oldLibraryItem
})
@ -89,9 +89,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, false, include, limit, 0)
return {
libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size
@ -107,9 +107,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, include, limit, 0)
return {
libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size
@ -136,9 +136,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library, user, include, limit, 0)
return {
libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.series) {
oldLibraryItem.media.metadata.series = li.series
@ -166,9 +166,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'finished', 'progress', true, false, include, limit, 0)
return {
items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare
@ -182,7 +182,7 @@ module.exports = {
return {
count,
items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = li.toOldJSONMinified()
oldLibraryItem.recentEpisode = li.recentEpisode
return oldLibraryItem
})
@ -279,7 +279,7 @@ module.exports = {
const oldSeries = s.toOldJSON()
if (s.feeds?.length) {
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified()
oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()
}
// TODO: Sort books by sequence in query
@ -293,15 +293,17 @@ module.exports = {
})
oldSeries.books = s.bookSeries
.map((bs) => {
const libraryItem = bs.book.libraryItem?.toJSON()
const libraryItem = bs.book.libraryItem
if (!libraryItem) {
Logger.warn(`Book series book has no libraryItem`, bs, bs.book, 'series=', series)
return null
}
delete bs.book.libraryItem
bs.book.authors = [] // Not needed
bs.book.series = [] // Not needed
libraryItem.media = bs.book
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified()
const oldLibraryItem = libraryItem.toOldJSONMinified()
return oldLibraryItem
})
.filter((b) => b)
@ -373,9 +375,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, user, include, limit)
return {
libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
}
if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare
@ -400,7 +402,7 @@ module.exports = {
return {
count,
libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = li.toOldJSONMinified()
oldLibraryItem.recentEpisode = li.recentEpisode
return oldLibraryItem
})
@ -413,7 +415,7 @@ module.exports = {
* @param {import('../../models/User')} user
* @param {number} limit
* @param {number} offset
* @returns {Promise<{ libraryItems:import('../../objects/LibraryItem')[], count:number }>}
* @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}
*/
async getLibraryItemsForAuthor(author, user, limit, offset) {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(author.libraryId, user, 'authors', author.id, 'addedAt', true, false, [], limit, offset)

View file

@ -349,7 +349,7 @@ module.exports = {
* @param {number} limit
* @param {number} offset
* @param {boolean} isHomePage for home page shelves
* @returns {object} { libraryItems:LibraryItem[], count:number }
* @returns {{ libraryItems: import('../../models/LibraryItem')[], count: number }}
*/
async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset, isHomePage = false) {
// TODO: Handle collapse sub-series
@ -583,8 +583,8 @@ module.exports = {
})
const libraryItems = books.map((bookExpanded) => {
const libraryItem = bookExpanded.libraryItem.toJSON()
const book = bookExpanded.toJSON()
const libraryItem = bookExpanded.libraryItem
const book = bookExpanded
if (filterGroup === 'series' && book.series?.length) {
// For showing sequence on book cover when filtering for series
@ -596,19 +596,29 @@ module.exports = {
}
delete book.libraryItem
delete book.authors
delete book.series
book.series =
book.bookSeries?.map((bs) => {
const series = bs.series
delete bs.series
series.bookSeries = bs
return series
}) || []
delete book.bookSeries
book.authors = book.bookAuthors?.map((ba) => ba.author) || []
delete book.bookAuthors
// For showing details of collapsed series
if (collapseseries && book.bookSeries?.length) {
const collapsedSeries = book.bookSeries.find((bs) => collapseSeriesBookSeries.some((cbs) => cbs.id === bs.id))
if (collapseseries && book.series?.length) {
const collapsedSeries = book.series.find((bs) => collapseSeriesBookSeries.some((cbs) => cbs.id === bs.bookSeries.id))
if (collapsedSeries) {
const collapseSeriesObj = collapseSeriesBookSeries.find((csbs) => csbs.id === collapsedSeries.id)
const collapseSeriesObj = collapseSeriesBookSeries.find((csbs) => csbs.id === collapsedSeries.bookSeries.id)
libraryItem.collapsedSeries = {
id: collapsedSeries.series.id,
name: collapsedSeries.series.name,
nameIgnorePrefix: collapsedSeries.series.nameIgnorePrefix,
sequence: collapsedSeries.sequence,
id: collapsedSeries.id,
name: collapsedSeries.name,
nameIgnorePrefix: collapsedSeries.nameIgnorePrefix,
sequence: collapsedSeries.bookSeries.sequence,
numBooks: collapseSeriesObj?.numBooks || 0,
libraryItemIds: collapseSeriesObj?.libraryItemIds || []
}
@ -646,7 +656,7 @@ module.exports = {
* @param {string[]} include
* @param {number} limit
* @param {number} offset
* @returns {{ libraryItems:import('../../models/LibraryItem')[], count:number }}
* @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}
*/
async getContinueSeriesLibraryItems(library, user, include, limit, offset) {
const libraryId = library.id
@ -758,9 +768,12 @@ module.exports = {
}
}
const libraryItem = s.bookSeries[bookIndex].book.libraryItem.toJSON()
const book = s.bookSeries[bookIndex].book.toJSON()
const libraryItem = s.bookSeries[bookIndex].book.libraryItem
const book = s.bookSeries[bookIndex].book
delete book.libraryItem
book.series = []
libraryItem.series = {
id: s.id,
name: s.name,
@ -788,7 +801,7 @@ module.exports = {
* @param {import('../../models/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} {libraryItems:LibraryItem, count:number}
* @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}
*/
async getDiscoverLibraryItems(libraryId, user, include, limit) {
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
@ -895,9 +908,22 @@ module.exports = {
// Step 3: Map books to library items
const libraryItems = books.map((bookExpanded) => {
const libraryItem = bookExpanded.libraryItem.toJSON()
const book = bookExpanded.toJSON()
const libraryItem = bookExpanded.libraryItem
const book = bookExpanded
delete book.libraryItem
book.series =
book.bookSeries?.map((bs) => {
const series = bs.series
delete bs.series
series.bookSeries = bs
return series
}) || []
delete book.bookSeries
book.authors = book.bookAuthors?.map((ba) => ba.author) || []
delete book.bookAuthors
libraryItem.media = book
if (libraryItem.feeds?.length) {
@ -961,11 +987,11 @@ module.exports = {
* Get library items for series
* @param {import('../../models/Series')} series
* @param {import('../../models/User')} [user]
* @returns {Promise<import('../../objects/LibraryItem')[]>}
* @returns {Promise<import('../../models/LibraryItem')[]>}
*/
async getLibraryItemsForSeries(series, user) {
const { libraryItems } = await this.getFilteredLibraryItems(series.libraryId, user, 'series', series.id, null, null, false, [], null, null)
return libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li))
return libraryItems
},
/**
@ -1040,9 +1066,21 @@ module.exports = {
for (const book of books) {
const libraryItem = book.libraryItem
delete book.libraryItem
book.series = book.bookSeries.map((bs) => {
const series = bs.series
delete bs.series
series.bookSeries = bs
return series
})
delete book.bookSeries
book.authors = book.bookAuthors.map((ba) => ba.author)
delete book.bookAuthors
libraryItem.media = book
itemMatches.push({
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
libraryItem: libraryItem.toOldJSONExpanded()
})
}
@ -1132,7 +1170,9 @@ module.exports = {
const books = series.bookSeries.map((bs) => {
const libraryItem = bs.book.libraryItem
libraryItem.media = bs.book
return Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSON()
libraryItem.media.authors = []
libraryItem.media.series = []
return libraryItem.toOldJSON()
})
seriesMatches.push({
series: series.toOldJSON(),

View file

@ -107,7 +107,7 @@ module.exports = {
* @param {string[]} include
* @param {number} limit
* @param {number} offset
* @returns {object} { libraryItems:LibraryItem[], count:number }
* @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}
*/
async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) {
const includeRSSFeed = include.includes('rssfeed')
@ -175,16 +175,19 @@ module.exports = {
})
const libraryItems = podcasts.map((podcastExpanded) => {
const libraryItem = podcastExpanded.libraryItem.toJSON()
const podcast = podcastExpanded.toJSON()
const libraryItem = podcastExpanded.libraryItem
const podcast = podcastExpanded
delete podcast.libraryItem
if (libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0]
}
if (podcast.numEpisodesIncomplete) {
libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete
if (podcast.dataValues.numEpisodesIncomplete) {
libraryItem.numEpisodesIncomplete = podcast.dataValues.numEpisodesIncomplete
}
if (podcast.dataValues.numEpisodes) {
podcast.numEpisodes = podcast.dataValues.numEpisodes
}
libraryItem.media = podcast
@ -209,7 +212,7 @@ module.exports = {
* @param {number} limit
* @param {number} offset
* @param {boolean} isHomePage for home page shelves
* @returns {object} {libraryItems:LibraryItem[], count:number}
* @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}
*/
async getFilteredPodcastEpisodes(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, limit, offset, isHomePage = false) {
if (sortBy === 'progress' && filterGroup !== 'progress') {
@ -289,11 +292,12 @@ module.exports = {
})
const libraryItems = podcastEpisodes.map((ep) => {
const libraryItem = ep.podcast.libraryItem.toJSON()
const podcast = ep.podcast.toJSON()
const libraryItem = ep.podcast.libraryItem
const podcast = ep.podcast
delete podcast.libraryItem
libraryItem.media = podcast
libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON()
libraryItem.recentEpisode = ep.toOldJSON(libraryItem.id)
return libraryItem
})
@ -362,8 +366,9 @@ module.exports = {
const libraryItem = podcast.libraryItem
delete podcast.libraryItem
libraryItem.media = podcast
libraryItem.media.podcastEpisodes = []
itemMatches.push({
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
libraryItem: libraryItem.toOldJSONExpanded()
})
}
@ -455,13 +460,14 @@ module.exports = {
})
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
ep.podcast.podcastEpisodes = [] // Not needed
const oldPodcastJson = ep.podcast.toOldJSON(ep.podcast.libraryItem.id)
const oldPodcastEpisodeJson = ep.toOldJSONExpanded(ep.podcast.libraryItem.id)
oldPodcastEpisodeJson.podcast = oldPodcastJson
oldPodcastEpisodeJson.libraryId = ep.podcast.libraryItem.libraryId
return oldPodcastEpisodeJson
})
return episodeResults

View file

@ -162,6 +162,12 @@ module.exports = {
include: [
{
model: Database.libraryItemModel
},
{
model: Database.authorModel
},
{
model: Database.seriesModel
}
]
},
@ -182,7 +188,7 @@ module.exports = {
}
if (s.feeds?.length) {
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified()
oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()
}
// TODO: Sort books by sequence in query
@ -195,10 +201,10 @@ module.exports = {
})
})
oldSeries.books = s.bookSeries.map((bs) => {
const libraryItem = bs.book.libraryItem.toJSON()
const libraryItem = bs.book.libraryItem
delete bs.book.libraryItem
libraryItem.media = bs.book
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified()
const oldLibraryItem = libraryItem.toOldJSONMinified()
return oldLibraryItem
})
allOldSeries.push(oldSeries)

View file

@ -127,20 +127,20 @@ module.exports = {
bookListeningMap[ls.displayTitle] += listeningSessionListeningTime
}
const authors = ls.mediaMetadata.authors || []
const authors = ls.mediaMetadata?.authors || []
authors.forEach((au) => {
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
authorListeningMap[au.name] += listeningSessionListeningTime
})
const narrators = ls.mediaMetadata.narrators || []
const narrators = ls.mediaMetadata?.narrators || []
narrators.forEach((narrator) => {
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
narratorListeningMap[narrator] += listeningSessionListeningTime
})
// Filter out bad genres like "audiobook" and "audio book"
const genres = (ls.mediaMetadata.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
genres.forEach((genre) => {
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
genreListeningMap[genre] += listeningSessionListeningTime

View file

@ -7,7 +7,7 @@ module.exports.zipDirectoryPipe = (path, filename, res) => {
res.attachment(filename)
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
zlib: { level: 0 } // Sets the compression level.
})
// listen for all archive data to be written
@ -49,4 +49,4 @@ module.exports.zipDirectoryPipe = (path, filename, res) => {
archive.finalize()
})
}
}