mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-23 04:09:38 +00:00
Merge remote-tracking branch 'remotes/upstream/master' into allow-mrss-item-enclosures-for-podcasts
This commit is contained in:
commit
bdd8e5bb58
116 changed files with 4080 additions and 3726 deletions
|
|
@ -401,45 +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)))
|
||||
}
|
||||
|
||||
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 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)
|
||||
|
|
@ -665,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() {
|
||||
|
|
@ -695,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: {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,12 @@ class Server {
|
|||
}
|
||||
}
|
||||
|
||||
if (process.env.PODCAST_DOWNLOAD_TIMEOUT) {
|
||||
global.PodcastDownloadTimeout = process.env.PODCAST_DOWNLOAD_TIMEOUT
|
||||
} else {
|
||||
global.PodcastDownloadTimeout = 30000
|
||||
}
|
||||
|
||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||
fs.mkdirSync(global.ConfigPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -221,7 +221,9 @@ class CollectionController {
|
|||
* @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')
|
||||
}
|
||||
|
|
@ -231,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()
|
||||
|
|
@ -255,7 +257,9 @@ class CollectionController {
|
|||
* @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)
|
||||
}
|
||||
|
|
@ -266,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()
|
||||
|
|
@ -274,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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,15 @@ class LibraryController {
|
|||
return res.status(400).send(`Invalid request. Settings "${key}" must be a string`)
|
||||
}
|
||||
newLibraryPayload.settings[key] = req.body.settings[key]
|
||||
} else if (key === 'markAsFinishedPercentComplete' || key === 'markAsFinishedTimeRemaining') {
|
||||
if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
|
||||
return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
|
||||
} else if (key === 'markAsFinishedPercentComplete' && req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) {
|
||||
return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 100`)
|
||||
} else if (key === 'markAsFinishedTimeRemaining' && req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) {
|
||||
return res.status(400).send(`Invalid request. Setting "${key}" must be greater than or equal to 0`)
|
||||
}
|
||||
newLibraryPayload.settings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
|
||||
} else {
|
||||
if (typeof req.body.settings[key] !== typeof newLibraryPayload.settings[key]) {
|
||||
return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof newLibraryPayload.settings[key]}`)
|
||||
|
|
@ -170,21 +179,34 @@ class LibraryController {
|
|||
* GET: /api/libraries
|
||||
* Get all libraries
|
||||
*
|
||||
* ?include=stats to load library stats - used in android auto to filter out libraries with no audio
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findAll(req, res) {
|
||||
const libraries = await Database.libraryModel.getAllWithFolders()
|
||||
let libraries = await Database.libraryModel.getAllWithFolders()
|
||||
|
||||
const librariesAccessible = req.user.permissions?.librariesAccessible || []
|
||||
if (librariesAccessible.length) {
|
||||
return res.json({
|
||||
libraries: libraries.filter((lib) => librariesAccessible.includes(lib.id)).map((lib) => lib.toOldJSON())
|
||||
})
|
||||
libraries = libraries.filter((lib) => librariesAccessible.includes(lib.id))
|
||||
}
|
||||
|
||||
libraries = libraries.map((lib) => lib.toOldJSON())
|
||||
|
||||
const includeArray = (req.query.include || '').split(',')
|
||||
if (includeArray.includes('stats')) {
|
||||
for (const library of libraries) {
|
||||
if (library.mediaType === 'book') {
|
||||
library.stats = await libraryItemsBookFilters.getBookLibraryStats(library.id)
|
||||
} else if (library.mediaType === 'podcast') {
|
||||
library.stats = await libraryItemsPodcastFilters.getPodcastLibraryStats(library.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
libraries: libraries.map((lib) => lib.toOldJSON())
|
||||
libraries
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -312,7 +334,7 @@ class LibraryController {
|
|||
}
|
||||
if (req.body.settings[key] !== updatedSettings[key]) {
|
||||
hasUpdates = true
|
||||
updatedSettings[key] = Number(req.body.settings[key])
|
||||
updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
|
||||
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
|
||||
}
|
||||
} else if (key === 'markAsFinishedTimeRemaining') {
|
||||
|
|
@ -325,7 +347,7 @@ class LibraryController {
|
|||
}
|
||||
if (req.body.settings[key] !== updatedSettings[key]) {
|
||||
hasUpdates = true
|
||||
updatedSettings[key] = Number(req.body.settings[key])
|
||||
updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
|
||||
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1145,14 +1167,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())
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1189,14 +1211,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())
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,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 {
|
||||
|
|
@ -35,17 +45,17 @@ 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)
|
||||
}
|
||||
|
||||
|
|
@ -68,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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -100,7 +89,7 @@ class LibraryItemController {
|
|||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
|
|
@ -111,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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,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) {
|
||||
|
|
@ -164,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}"`)
|
||||
|
||||
|
|
@ -194,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) {
|
||||
|
|
@ -206,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) {
|
||||
|
|
@ -276,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')
|
||||
}
|
||||
|
|
@ -295,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
|
||||
|
|
@ -308,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,
|
||||
|
|
@ -334,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)
|
||||
|
|
@ -353,7 +367,7 @@ class LibraryItemController {
|
|||
/**
|
||||
* GET: /api/items/:id/cover
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getCover(req, res) {
|
||||
|
|
@ -395,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)
|
||||
}
|
||||
|
|
@ -412,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)
|
||||
}
|
||||
|
||||
|
|
@ -433,30 +447,55 @@ 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) {
|
||||
const libraryItem = req.libraryItem
|
||||
const reqBody = req.body || {}
|
||||
|
||||
const options = {}
|
||||
|
|
@ -473,7 +512,7 @@ class LibraryItemController {
|
|||
options.overrideDetails = !!reqBody.overrideDetails
|
||||
}
|
||||
|
||||
var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(this, req.libraryItem, options)
|
||||
res.json(matchResult)
|
||||
}
|
||||
|
||||
|
|
@ -496,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
|
||||
})
|
||||
|
||||
|
|
@ -511,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)
|
||||
|
|
@ -568,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) {
|
||||
|
|
@ -585,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++
|
||||
}
|
||||
}
|
||||
|
|
@ -633,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())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -660,7 +719,7 @@ class LibraryItemController {
|
|||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: req.body.libraryItemIds
|
||||
})
|
||||
if (!libraryItems?.length) {
|
||||
|
|
@ -741,7 +800,7 @@ class LibraryItemController {
|
|||
/**
|
||||
* POST: /api/items/:id/scan
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async scan(req, res) {
|
||||
|
|
@ -765,7 +824,7 @@ class LibraryItemController {
|
|||
/**
|
||||
* GET: /api/items/:id/metadata-object
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getMetadataObject(req, res) {
|
||||
|
|
@ -774,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)
|
||||
}
|
||||
|
|
@ -785,7 +844,7 @@ class LibraryItemController {
|
|||
/**
|
||||
* POST: /api/items/:id/chapters
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateMediaChapters(req, res) {
|
||||
|
|
@ -794,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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -821,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) {
|
||||
|
|
@ -829,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) {
|
||||
|
|
@ -870,7 +952,7 @@ class LibraryItemController {
|
|||
/**
|
||||
* DELETE api/items/:id/file/:fileid
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {LibraryItemControllerRequestWithFile} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async deleteLibraryFile(req, res) {
|
||||
|
|
@ -881,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)
|
||||
}
|
||||
|
||||
|
|
@ -899,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) {
|
||||
|
|
@ -911,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)
|
||||
|
|
@ -947,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')
|
||||
|
|
@ -963,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)
|
||||
|
|
@ -991,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)
|
||||
}
|
||||
|
||||
|
|
@ -1023,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
|
||||
|
|
@ -1033,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -342,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++
|
||||
}
|
||||
}
|
||||
|
|
@ -385,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++
|
||||
}
|
||||
|
||||
|
|
@ -480,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++
|
||||
}
|
||||
}
|
||||
|
|
@ -523,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++
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ class PlaylistController {
|
|||
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')
|
||||
}
|
||||
|
|
@ -286,7 +286,7 @@ class PlaylistController {
|
|||
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')
|
||||
}
|
||||
|
||||
|
|
@ -308,17 +308,17 @@ class PlaylistController {
|
|||
|
||||
// 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.episodes.find((ep) => ep.id === itemToAdd.episodeId)
|
||||
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === itemToAdd.episodeId)
|
||||
jsonExpanded.items.push({
|
||||
episodeId: itemToAdd.episodeId,
|
||||
episode: episode.toJSONExpanded(),
|
||||
episode: episode.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toJSONMinified()
|
||||
libraryItem: libraryItem.toOldJSONMinified()
|
||||
})
|
||||
} else {
|
||||
jsonExpanded.items.push({
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toJSONExpanded()
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -388,8 +388,8 @@ class PlaylistController {
|
|||
// Find all library items
|
||||
const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
|
||||
|
||||
const oldLibraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: Array.from(libraryItemIds) })
|
||||
if (oldLibraryItems.length !== libraryItemIds.size) {
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) })
|
||||
if (libraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
|
||||
|
|
@ -401,7 +401,7 @@ class PlaylistController {
|
|||
// Setup array of playlistMediaItem records to add
|
||||
let order = req.playlist.playlistMediaItems.length + 1
|
||||
for (const item of req.body.items) {
|
||||
const libraryItem = oldLibraryItems.find((li) => li.id === item.libraryItemId)
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
|
||||
const mediaItemId = item.episodeId || libraryItem.media.id
|
||||
if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
|
||||
|
|
@ -417,17 +417,17 @@ class PlaylistController {
|
|||
|
||||
// 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.episodes.find((ep) => ep.id === item.episodeId)
|
||||
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === item.episodeId)
|
||||
jsonExpanded.items.push({
|
||||
episodeId: item.episodeId,
|
||||
episode: episode.toJSONExpanded(),
|
||||
episode: episode.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toJSONMinified()
|
||||
libraryItem: libraryItem.toOldJSONMinified()
|
||||
})
|
||||
} else {
|
||||
jsonExpanded.items.push({
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toJSONExpanded()
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,61 +388,82 @@ 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)
|
||||
}
|
||||
|
||||
// Remove it from the podcastEpisodes array
|
||||
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeId)
|
||||
|
||||
if (hardDelete) {
|
||||
const audioFile = episode.audioFile
|
||||
// TODO: this will trigger the watcher. should maybe handle this gracefully
|
||||
|
|
@ -407,36 +477,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 +490,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 +509,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 +529,7 @@ class PodcastController {
|
|||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
req.libraryItem = item
|
||||
req.libraryItem = libraryItem
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || ''
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,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,
|
||||
|
|
@ -86,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
|
||||
|
|
@ -105,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -175,7 +175,8 @@ class PlaybackSessionManager {
|
|||
// New session from local
|
||||
session = new PlaybackSession(sessionJson)
|
||||
session.deviceInfo = deviceInfo
|
||||
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 {
|
||||
|
|
@ -279,7 +280,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>}
|
||||
|
|
@ -292,7 +293,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
|
||||
|
|
@ -300,7 +301,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
|
||||
|
|
@ -312,7 +313,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})`)
|
||||
|
|
@ -342,20 +343,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
|
||||
|
|
@ -381,9 +382,8 @@ class PlaybackSessionManager {
|
|||
})
|
||||
}
|
||||
this.saveSession(session)
|
||||
return {
|
||||
libraryItem
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
|
@ -19,9 +20,7 @@ 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() {
|
||||
|
|
@ -52,15 +51,16 @@ 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -86,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
|
||||
|
|
@ -115,10 +115,24 @@ class PodcastManager {
|
|||
let success = false
|
||||
if (this.currentDownload.isMp3) {
|
||||
// Download episode and tag it
|
||||
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
||||
const ffmpegDownloadResponse = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
return false
|
||||
})
|
||||
success = !!ffmpegDownloadResponse?.success
|
||||
|
||||
// If failed due to ffmpeg error, retry without tagging
|
||||
// e.g. RSS feed may have incorrect file extension and file type
|
||||
// See https://github.com/advplyr/audiobookshelf/issues/3837
|
||||
if (!success && ffmpegDownloadResponse?.isFfmpegError) {
|
||||
Logger.info(`[PodcastManager] Retrying episode download without tagging`)
|
||||
// Download episode only
|
||||
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Download episode only
|
||||
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
|
||||
|
|
@ -140,7 +154,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()
|
||||
}
|
||||
|
|
@ -166,47 +180,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) {
|
||||
|
|
@ -217,45 +245,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)
|
||||
|
|
@ -263,18 +299,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) {
|
||||
|
|
@ -283,37 +324,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
|
||||
|
|
@ -326,23 +378,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) {
|
||||
|
|
@ -518,64 +581,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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -60,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 {
|
||||
|
|
@ -113,158 +122,12 @@ class Book extends Model {
|
|||
/** @type {Date} */
|
||||
this.createdAt
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./Author')[]} - optional if expanded */
|
||||
this.authors
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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('./Series')[]} - optional if expanded */
|
||||
this.series
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -343,18 +206,459 @@ class Book extends Model {
|
|||
}
|
||||
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 trackList() {
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ class Collection extends Model {
|
|||
const libraryItem = book.libraryItem
|
||||
delete book.libraryItem
|
||||
libraryItem.media = book
|
||||
return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded()
|
||||
return libraryItem.toOldJSONExpanded()
|
||||
})
|
||||
|
||||
return json
|
||||
|
|
|
|||
|
|
@ -112,15 +112,15 @@ class FeedEpisode extends Model {
|
|||
/**
|
||||
* 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(book) {
|
||||
const tracks = book.trackList || []
|
||||
static checkUseChapterTitlesForEpisodes(trackList, book) {
|
||||
const chapters = book.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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,8 @@ class FeedEpisode extends Model {
|
|||
*/
|
||||
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
|
||||
// Offset pubdate in 1 minute intervals to ensure correct order
|
||||
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 60000
|
||||
let episodeId = existingEpisodeId || uuidv4()
|
||||
|
||||
// e.g. Track 1 will have a pub date before Track 2
|
||||
|
|
@ -148,7 +149,7 @@ class FeedEpisode extends Model {
|
|||
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.trackList.length == 1) {
|
||||
if (book.includedAudioFiles.length == 1) {
|
||||
// If audiobook is a single file, use book title instead of chapter/file title
|
||||
title = book.title
|
||||
} else {
|
||||
|
|
@ -185,11 +186,12 @@ class FeedEpisode extends Model {
|
|||
* @returns {Promise<FeedEpisode[]>}
|
||||
*/
|
||||
static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) {
|
||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media)
|
||||
const trackList = libraryItemExpanded.getTrackList()
|
||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, libraryItemExpanded.media)
|
||||
|
||||
const feedEpisodeObjs = []
|
||||
let numExisting = 0
|
||||
for (const track of libraryItemExpanded.media.trackList) {
|
||||
for (const track of trackList) {
|
||||
// Check for existing episode by filepath
|
||||
const existingEpisode = feed.feedEpisodes?.find((episode) => {
|
||||
return episode.filePath === track.metadata.path
|
||||
|
|
@ -204,7 +206,7 @@ class FeedEpisode extends Model {
|
|||
|
||||
/**
|
||||
*
|
||||
* @param {import('./Book')[]} books
|
||||
* @param {import('./Book').BookExpandedWithLibraryItem[]} books
|
||||
* @param {import('./Feed')} feed
|
||||
* @param {string} slug
|
||||
* @param {import('sequelize').Transaction} transaction
|
||||
|
|
@ -218,8 +220,9 @@ class FeedEpisode extends Model {
|
|||
const feedEpisodeObjs = []
|
||||
let numExisting = 0
|
||||
for (const book of books) {
|
||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book)
|
||||
for (const track of book.trackList) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -123,12 +120,27 @@ class LibraryItem extends Model {
|
|||
}
|
||||
|
||||
/**
|
||||
* Remove library item by id
|
||||
*
|
||||
* @param {import('sequelize').WhereOptions} [where]
|
||||
* @returns {Array<objects.LibraryItem>} old library items
|
||||
* @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: [
|
||||
{
|
||||
|
|
@ -150,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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -498,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
|
||||
}
|
||||
|
||||
|
|
@ -531,7 +262,7 @@ class LibraryItem extends Model {
|
|||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
attributes: ['id', 'sequence']
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
@ -543,7 +274,7 @@ class LibraryItem extends Model {
|
|||
}
|
||||
|
||||
if (!libraryItem.media) return null
|
||||
return this.getOldLibraryItem(libraryItem)
|
||||
return libraryItem
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -551,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()
|
||||
|
|
@ -560,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
|
||||
}
|
||||
|
|
@ -605,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
|
||||
|
|
@ -714,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) {
|
||||
|
|
@ -766,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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -792,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
|
||||
|
|
@ -865,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}
|
||||
|
|
@ -1009,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
|
||||
})
|
||||
|
|
@ -1131,6 +762,64 @@ 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
|
||||
|
|
@ -1142,12 +831,149 @@ class LibraryItem extends Model {
|
|||
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
|
||||
return false
|
||||
}
|
||||
if (this.mediaType === 'book') {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -76,42 +76,23 @@ class MediaItemShare extends Model {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
|
||||
class Playlist extends Model {
|
||||
constructor(values, options) {
|
||||
|
|
@ -163,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
|
||||
|
|
@ -313,7 +357,7 @@ class Playlist extends Model {
|
|||
libraryItem.media = pmi.mediaItem
|
||||
return {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded()
|
||||
libraryItem: libraryItem.toOldJSONExpanded()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -324,7 +368,7 @@ class Playlist extends Model {
|
|||
episodeId: pmi.mediaItemId,
|
||||
episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified()
|
||||
libraryItem: libraryItem.toOldJSONMinified()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -171,20 +134,50 @@ class PodcastEpisode extends Model {
|
|||
PodcastEpisode.belongsTo(podcast)
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.audioFile?.metadata.size || 0
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return this.audioFile?.duration || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* AudioTrack object used in old model
|
||||
* Used for matching the episode with an episode in the RSS feed
|
||||
*
|
||||
* @returns {import('./Book').AudioFileObject|null}
|
||||
* @param {string} guid
|
||||
* @param {string} enclosureURL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get track() {
|
||||
if (!this.audioFile) return null
|
||||
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.title
|
||||
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 = {
|
||||
|
|
@ -209,8 +202,8 @@ class PodcastEpisode extends Model {
|
|||
enclosure,
|
||||
guid: this.extraData?.guid || null,
|
||||
pubDate: this.pubDate,
|
||||
chapters: this.chapters?.map((ch) => ({ ...ch })) || [],
|
||||
audioFile: this.audioFile || null,
|
||||
chapters: structuredClone(this.chapters),
|
||||
audioFile: structuredClone(this.audioFile),
|
||||
publishedAt: this.publishedAt?.valueOf() || null,
|
||||
addedAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf()
|
||||
|
|
@ -220,9 +213,9 @@ class PodcastEpisode extends Model {
|
|||
toOldJSONExpanded(libraryItemId) {
|
||||
const json = this.toOldJSON(libraryItemId)
|
||||
|
||||
json.audioTrack = this.track
|
||||
json.size = this.audioFile?.metadata.size || 0
|
||||
json.duration = this.audioFile?.duration || 0
|
||||
json.audioTrack = this.getAudioTrack(libraryItemId)
|
||||
json.size = this.size
|
||||
json.duration = this.duration
|
||||
|
||||
return json
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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) {
|
||||
|
|
@ -66,6 +66,22 @@ class Series extends Model {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +57,7 @@ class PodcastEpisodeDownload {
|
|||
return 'mp3'
|
||||
}
|
||||
get enclosureType() {
|
||||
const enclosureType = this.podcastEpisode?.enclosure?.type
|
||||
const enclosureType = this.rssPodcastEpisode.enclosure.type
|
||||
return typeof enclosureType === 'string' ? enclosureType : null
|
||||
}
|
||||
/**
|
||||
|
|
@ -67,10 +70,12 @@ class PodcastEpisodeDownload {
|
|||
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() {
|
||||
|
|
@ -80,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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -65,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))
|
||||
|
|
@ -105,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))
|
||||
|
|
@ -361,36 +360,7 @@ 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 RssFeedManager.closeFeedForEntityId(libraryItemId)
|
||||
|
|
@ -560,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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -499,16 +499,17 @@ class AudioFileScanner {
|
|||
// Filter these out and log a warning
|
||||
// See https://github.com/advplyr/audiobookshelf/issues/3361
|
||||
const afChaptersCleaned =
|
||||
file.chapters?.filter((c) => {
|
||||
file.chapters?.filter((c, i) => {
|
||||
if (c.end - c.start < 0.1) {
|
||||
libraryScan.addLog(LogLevel.WARN, `Chapter "${c.title}" has invalid duration of ${c.end - c.start} seconds. Skipping this chapter.`)
|
||||
libraryScan.addLog(LogLevel.WARN, `Audio file "${file.metadata.filename}" Chapter "${c.title}" (index ${i}) has invalid duration of ${c.end - c.start} seconds. Skipping this chapter.`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}) || []
|
||||
const afChapters = afChaptersCleaned.map((c) => ({
|
||||
|
||||
const afChapters = afChaptersCleaned.map((c, i) => ({
|
||||
...c,
|
||||
id: c.id + currChapterId,
|
||||
id: currChapterId + i,
|
||||
start: c.start + currStartTime,
|
||||
end: c.end + currStartTime
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -30,14 +30,14 @@ class Scanner {
|
|||
/**
|
||||
*
|
||||
* @param {import('../routers/ApiRouter')} apiRouterCtx
|
||||
* @param {import('../objects/LibraryItem')} libraryItem
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {QuickMatchOptions} options
|
||||
* @returns {Promise<{updated: boolean, libraryItem: import('../objects/LibraryItem')}>}
|
||||
* @returns {Promise<{updated: boolean, libraryItem: Object}>}
|
||||
*/
|
||||
async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) {
|
||||
const provider = options.provider || 'google'
|
||||
const searchTitle = options.title || libraryItem.media.metadata.title
|
||||
const searchAuthor = options.author || libraryItem.media.metadata.authorName
|
||||
const searchTitle = options.title || libraryItem.media.title
|
||||
const searchAuthor = options.author || libraryItem.media.authorName
|
||||
|
||||
// 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) {
|
||||
|
|
@ -52,11 +52,11 @@ class Scanner {
|
|||
let existingSeries = []
|
||||
|
||||
if (libraryItem.isBook) {
|
||||
existingAuthors = libraryItem.media.metadata.authors.map((a) => a.id)
|
||||
existingSeries = libraryItem.media.metadata.series.map((s) => s.id)
|
||||
existingAuthors = libraryItem.media.authors.map((a) => a.id)
|
||||
existingSeries = libraryItem.media.series.map((s) => s.id)
|
||||
|
||||
const searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
||||
const searchASIN = options.asin || libraryItem.media.metadata.asin
|
||||
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) {
|
||||
|
|
@ -69,15 +69,21 @@ class Scanner {
|
|||
// 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
|
||||
const results = await PodcastFinder.search(searchTitle)
|
||||
|
|
@ -91,10 +97,12 @@ class Scanner {
|
|||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -103,44 +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()
|
||||
|
||||
// Check if any authors or series are now empty and should be removed
|
||||
if (libraryItem.isBook) {
|
||||
const authorsRemoved = existingAuthors.filter((aid) => !libraryItem.media.metadata.authors.find((au) => au.id === aid))
|
||||
const seriesRemoved = existingSeries.filter((sid) => !libraryItem.media.metadata.series.find((se) => se.id === sid))
|
||||
libraryItem.changed('updatedAt', true)
|
||||
await libraryItem.save()
|
||||
|
||||
if (authorsRemoved.length) {
|
||||
await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorsRemoved)
|
||||
}
|
||||
if (seriesRemoved.length) {
|
||||
await apiRouterCtx.checkRemoveEmptySeries(seriesRemoved)
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
|
@ -158,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 {
|
||||
|
|
@ -169,46 +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
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../objects/LibraryItem')} libraryItem
|
||||
* @param {import('../routers/ApiRouter')} apiRouterCtx
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {*} matchData
|
||||
* @param {QuickMatchOptions} options
|
||||
* @returns
|
||||
* @returns {Promise<{updatePayload: Map<string, any>, seriesIdsRemoved: string[], hasSeriesUpdates: boolean, authorIdsRemoved: string[], hasAuthorUpdates: boolean}>}
|
||||
*/
|
||||
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
|
||||
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
|
||||
|
|
@ -218,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]
|
||||
|
|
@ -231,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 || '',
|
||||
|
|
@ -328,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
|
||||
}
|
||||
|
|
@ -351,7 +435,7 @@ class Scanner {
|
|||
*
|
||||
* @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
|
||||
*/
|
||||
|
|
@ -359,20 +443,20 @@ class Scanner {
|
|||
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})`)
|
||||
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++
|
||||
}
|
||||
|
|
@ -430,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(apiRouterCtx, library, oldLibraryItems, libraryScan)
|
||||
const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan)
|
||||
if (!shouldContinue) {
|
||||
isCanceled = true
|
||||
break
|
||||
|
|
|
|||
|
|
@ -189,8 +189,14 @@ class CbzStreamZipComicBookExtractor extends AbstractComicBookExtractor {
|
|||
}
|
||||
|
||||
close() {
|
||||
this.archive?.close()
|
||||
Logger.debug(`[CbzStreamZipComicBookExtractor] Closed comic book "${this.comicPath}"`)
|
||||
this.archive
|
||||
?.close()
|
||||
.then(() => {
|
||||
Logger.debug(`[CbzStreamZipComicBookExtractor] Closed comic book "${this.comicPath}"`)
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error(`[CbzStreamZipComicBookExtractor] Failed to close comic book "${this.comicPath}"`, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {Promise<{success: boolean, isFfmpegError?: boolean}>}
|
||||
*/
|
||||
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
||||
return new Promise(async (resolve) => {
|
||||
const response = await axios({
|
||||
|
|
@ -106,44 +110,49 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||
headers: {
|
||||
'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'
|
||||
},
|
||||
timeout: 30000
|
||||
timeout: global.PodcastDownloadTimeout
|
||||
}).catch((error) => {
|
||||
Logger.error(`[ffmpegHelpers] Failed to download podcast episode with url "${podcastEpisodeDownload.url}"`, error)
|
||||
return null
|
||||
})
|
||||
if (!response) return resolve(false)
|
||||
if (!response) {
|
||||
return resolve({
|
||||
success: false
|
||||
})
|
||||
}
|
||||
|
||||
/** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */
|
||||
const ffmpeg = Ffmpeg(response.data)
|
||||
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) {
|
||||
|
|
@ -172,7 +181,10 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||
if (stderrLines.length) {
|
||||
Logger.error(`Full stderr dump for episode url "${podcastEpisodeDownload.url}": ${stderrLines.join('\n')}`)
|
||||
}
|
||||
resolve(false)
|
||||
resolve({
|
||||
success: false,
|
||||
isFfmpegError: true
|
||||
})
|
||||
})
|
||||
ffmpeg.on('progress', (progress) => {
|
||||
let progressPercent = 0
|
||||
|
|
@ -184,7 +196,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||
})
|
||||
ffmpeg.on('end', () => {
|
||||
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`)
|
||||
resolve(podcastEpisodeDownload.targetPath)
|
||||
resolve({
|
||||
success: true
|
||||
})
|
||||
})
|
||||
ffmpeg.run()
|
||||
})
|
||||
|
|
@ -359,28 +373,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 +408,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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,9 @@ async function parse(ebookFile) {
|
|||
archive = createComicBookExtractor(comicPath)
|
||||
await archive.open()
|
||||
|
||||
const filePaths = await archive.getFilePaths()
|
||||
const filePaths = await archive.getFilePaths().catch((error) => {
|
||||
Logger.error(`[parseComicMetadata] Failed to get file paths from comic at "${comicPath}"`, error)
|
||||
})
|
||||
|
||||
// Sort the file paths in a natural order to get the first image
|
||||
filePaths.sort((a, b) => {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,77 @@ 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]
|
||||
}
|
||||
|
||||
function extractStringOrStringify(json) {
|
||||
try {
|
||||
if (typeof json[Object.keys(json)[0]]?.[0] === 'string') {
|
||||
return json[Object.keys(json)[0]][0]
|
||||
}
|
||||
// Handles case where html was included without being wrapped in CDATA
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function extractFirstArrayItemString(json, key) {
|
||||
const item = extractFirstArrayItem(json, key)
|
||||
if (!item) return ''
|
||||
if (typeof item === 'object') {
|
||||
if (item?.['_'] && typeof item['_'] === 'string') return item['_']
|
||||
|
||||
return extractStringOrStringify(item)
|
||||
}
|
||||
return typeof item === 'string' ? item : ''
|
||||
}
|
||||
|
||||
function extractImage(channel) {
|
||||
if (!channel.image || !channel.image.url || !channel.image.url.length) {
|
||||
if (!channel['itunes:image'] || !channel['itunes:image'].length || !channel['itunes:image'][0]['$']) {
|
||||
|
|
@ -58,7 +124,7 @@ function extractPodcastMetadata(channel) {
|
|||
}
|
||||
|
||||
if (channel['description']) {
|
||||
const rawDescription = extractFirstArrayItem(channel, 'description') || ''
|
||||
const rawDescription = extractFirstArrayItemString(channel, 'description')
|
||||
metadata.description = htmlSanitizer.sanitize(rawDescription.trim())
|
||||
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
|
||||
}
|
||||
|
|
@ -106,7 +172,8 @@ function extractEpisodeData(item) {
|
|||
|
||||
// Supposed to be the plaintext description but not always followed
|
||||
if (item['description']) {
|
||||
const rawDescription = extractFirstArrayItem(item, 'description') || ''
|
||||
const rawDescription = extractFirstArrayItemString(item, 'description')
|
||||
|
||||
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription.trim())
|
||||
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
|
||||
}
|
||||
|
|
@ -136,9 +203,7 @@ function extractEpisodeData(item) {
|
|||
const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
||||
arrayFields.forEach((key) => {
|
||||
const cleanKey = key.split(':').pop()
|
||||
let value = extractFirstArrayItem(item, key)
|
||||
if (value?.['_']) value = value['_']
|
||||
episode[cleanKey] = value
|
||||
episode[cleanKey] = extractFirstArrayItemString(item, key)
|
||||
})
|
||||
return episode
|
||||
}
|
||||
|
|
@ -227,7 +292,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}"`)
|
||||
|
|
@ -242,7 +307,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
|||
return axios({
|
||||
url: feedUrl,
|
||||
method: 'GET',
|
||||
timeout: 12000,
|
||||
timeout: global.PodcastDownloadTimeout,
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',
|
||||
|
|
@ -291,6 +356,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) {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ function parseChapters(_chapters) {
|
|||
.map((chap) => {
|
||||
let title = chap['TAG:title'] || chap.title || ''
|
||||
if (!title && chap.tags?.title) title = chap.tags.title
|
||||
title = title.trim()
|
||||
|
||||
const timebase = chap.time_base?.includes('/') ? Number(chap.time_base.split('/')[1]) : 1
|
||||
const start = !isNullOrNaN(chap.start_time) ? Number(chap.start_time) : !isNullOrNaN(chap.start) ? Number(chap.start) / timebase : 0
|
||||
|
|
|
|||
|
|
@ -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,7 +52,7 @@ 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 = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
|
|
@ -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,7 +89,7 @@ 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 = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ 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 = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
|
|
@ -136,7 +136,7 @@ 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 = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
|
|
@ -166,7 +166,7 @@ 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 = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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,7 +375,7 @@ 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 = li.rssFeed.toOldJSONMinified()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,27 +596,37 @@ 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 || []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bookExpanded.libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
|
||||
if (libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
||||
}
|
||||
|
||||
if (includeMediaItemShare) {
|
||||
|
|
@ -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,16 +768,19 @@ 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,
|
||||
sequence: s.bookSeries[bookIndex].sequence
|
||||
}
|
||||
if (s.bookSeries[bookIndex].book.libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = s.bookSeries[bookIndex].book.libraryItem.feeds[0]
|
||||
if (libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
||||
}
|
||||
libraryItem.media = book
|
||||
return libraryItem
|
||||
|
|
@ -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,13 +908,26 @@ 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 (bookExpanded.libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
|
||||
if (libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
||||
}
|
||||
|
||||
return libraryItem
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 (podcastExpanded.libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = podcastExpanded.libraryItem.feeds[0]
|
||||
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
|
||||
|
|
|
|||
|
|
@ -162,6 +162,12 @@ module.exports = {
|
|||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
},
|
||||
{
|
||||
model: Database.authorModel
|
||||
},
|
||||
{
|
||||
model: Database.seriesModel
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue