Compare commits

...

14 Commits

Author SHA1 Message Date
advplyr 1e9470b840 Update AuthorController library item usage and remove unused 2025-01-04 15:59:40 -06:00
advplyr 726a9eaea5 Fix local playback sync 2025-01-04 15:35:05 -06:00
advplyr 6d52f88a96 Update controllers to use toOldJSON functions 2025-01-04 15:20:41 -06:00
advplyr 7fae25a726 Merge pull request #3795 from advplyr/migrate-podcasts-new-library-item-2
Update podcasts to new library item model
2025-01-04 12:52:50 -06:00
advplyr d8823c8b1c Update podcasts to new library item model 2025-01-04 12:41:09 -06:00
advplyr 4a398f6113 Merge pull request #3789 from advplyr/migrate-podcasts-new-library-item
Update podcasts to new library item model
2025-01-03 16:59:13 -06:00
advplyr 69d1744496 Update podcasts to new library item model 2025-01-03 16:48:24 -06:00
advplyr 0357dc90d4 Update libraryItem.updatedAt on media update 2025-01-03 14:07:27 -06:00
advplyr 6cd874dffc Merge pull request #3787 from advplyr/fix-remove-episode-from-playlist
Fix remove episode from playlist
2025-01-03 13:04:18 -06:00
advplyr 6467a92de6 Remove media progress when deleting podcast episode audio file 2025-01-03 12:12:56 -06:00
advplyr 63466ec48b Fix deleting episode library file removes episode from playlist #3784 2025-01-03 12:06:20 -06:00
advplyr de7296eaab Merge pull request #3785 from advplyr/playback-session-use-new-library-item
Update PlaybackSession to use new library item model
2025-01-03 11:20:33 -06:00
advplyr c251f1899d Update PlaybackSession to use new library item model 2025-01-03 11:16:03 -06:00
advplyr d205c6f734 Merge pull request #3779 from advplyr/refactor-library-item
Refactor LibraryItem to use new model
2025-01-02 17:30:22 -06:00
46 changed files with 1308 additions and 934 deletions
@@ -170,6 +170,12 @@ export default {
this.show = false this.show = false
} }
}, },
libraryItemUpdated(libraryItem) {
const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId)
if (episode) {
this.episodeItem = episode
}
},
hotkey(action) { hotkey(action) {
if (action === this.$hotkeys.Modal.NEXT_PAGE) { if (action === this.$hotkeys.Modal.NEXT_PAGE) {
this.goNextEpisode() this.goNextEpisode()
@@ -178,9 +184,15 @@ export default {
} }
}, },
registerListeners() { registerListeners() {
if (this.libraryItem) {
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
}
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
}, },
unregisterListeners() { unregisterListeners() {
if (this.libraryItem) {
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
}
this.$eventBus.$off('modal-hotkey', this.hotkey) this.$eventBus.$off('modal-hotkey', this.hotkey)
} }
}, },
@@ -163,13 +163,10 @@ export default {
this.isProcessing = false this.isProcessing = false
if (updateResult) { if (updateResult) {
if (updateResult) { this.$toast.success(this.$strings.ToastItemUpdateSuccess)
this.$toast.success(this.$strings.ToastItemUpdateSuccess) return true
return true
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
} }
return false return false
} }
}, },
+44 -23
View File
@@ -401,17 +401,6 @@ class Database {
return this.models.setting.updateSettingObj(settings.toJSON()) 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 * Save metadata file and update library item
* *
@@ -429,17 +418,6 @@ class Database {
return updated 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) { getPlaybackSessions(where = null) {
if (!this.sequelize) return false if (!this.sequelize) return false
return this.models.playbackSession.getOldPlaybackSessions(where) return this.models.playbackSession.getOldPlaybackSessions(where)
@@ -665,7 +643,7 @@ class Database {
/** /**
* Clean invalid records in database * Clean invalid records in database
* Series should have atleast one Book * 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 * Remove playback sessions that are 3 seconds or less
*/ */
async cleanDatabase() { async cleanDatabase() {
@@ -695,6 +673,49 @@ class Database {
await book.destroy() 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 // Remove empty series
const emptySeries = await this.seriesModel.findAll({ const emptySeries = await this.seriesModel.findAll({
include: { include: {
+24 -20
View File
@@ -44,16 +44,21 @@ class AuthorController {
// Used on author landing page to include library items and items grouped in series // Used on author landing page to include library items and items grouped in series
if (include.includes('items')) { 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')) { if (include.includes('series')) {
const seriesMap = {} const seriesMap = {}
// Group items into series // Group items into series
authorJson.libraryItems.forEach((li) => { libraryItems.forEach((li) => {
if (li.media.metadata.series) { if (li.media.series?.length) {
li.media.metadata.series.forEach((series) => { li.media.series.forEach((series) => {
const itemWithSeries = li.toJSONMinified() const itemWithSeries = li.toOldJSONMinified()
itemWithSeries.media.metadata.series = series itemWithSeries.media.metadata.series = {
id: series.id,
name: series.name,
nameIgnorePrefix: series.nameIgnorePrefix,
sequence: series.bookSeries.sequence
}
if (seriesMap[series.id]) { if (seriesMap[series.id]) {
seriesMap[series.id].items.push(itemWithSeries) seriesMap[series.id].items.push(itemWithSeries)
@@ -76,7 +81,7 @@ class AuthorController {
} }
// Minify library items // Minify library items
authorJson.libraryItems = authorJson.libraryItems.map((li) => li.toJSONMinified()) authorJson.libraryItems = libraryItems.map((li) => li.toOldJSONMinified())
} }
return res.json(authorJson) return res.json(authorJson)
@@ -125,7 +130,7 @@ class AuthorController {
const bookAuthorsToCreate = [] const bookAuthorsToCreate = []
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id) const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
const oldLibraryItems = [] const libraryItems = []
allItemsWithAuthor.forEach((libraryItem) => { allItemsWithAuthor.forEach((libraryItem) => {
// Replace old author with merging author for each book // Replace old author with merging author for each book
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id) libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
@@ -134,23 +139,22 @@ class AuthorController {
name: existingAuthor.name name: existingAuthor.name
}) })
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) libraryItems.push(libraryItem)
oldLibraryItems.push(oldLibraryItem)
bookAuthorsToCreate.push({ bookAuthorsToCreate.push({
bookId: libraryItem.media.id, bookId: libraryItem.media.id,
authorId: existingAuthor.id authorId: existingAuthor.id
}) })
}) })
if (oldLibraryItems.length) { if (libraryItems.length) {
await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
for (const libraryItem of allItemsWithAuthor) { for (const libraryItem of libraryItems) {
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
} }
SocketAuthority.emitter( SocketAuthority.emitter(
'items_updated', '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) const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
numBooksForAuthor = allItemsWithAuthor.length numBooksForAuthor = allItemsWithAuthor.length
const oldLibraryItems = [] const libraryItems = []
// Update author name on all books // Update author name on all books
for (const libraryItem of allItemsWithAuthor) { for (const libraryItem of allItemsWithAuthor) {
libraryItem.media.authors = libraryItem.media.authors.map((au) => { libraryItem.media.authors = libraryItem.media.authors.map((au) => {
@@ -199,16 +203,16 @@ class AuthorController {
} }
return au return au
}) })
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
oldLibraryItems.push(oldLibraryItem) libraryItems.push(libraryItem)
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
} }
if (oldLibraryItems.length) { if (libraryItems.length) {
SocketAuthority.emitter( SocketAuthority.emitter(
'items_updated', 'items_updated',
oldLibraryItems.map((li) => li.toJSONExpanded()) libraryItems.map((li) => li.toOldJSONExpanded())
) )
} }
} else { } else {
+10 -6
View File
@@ -221,7 +221,9 @@ class CollectionController {
* @param {Response} res * @param {Response} res
*/ */
async addBook(req, 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) { if (!libraryItem) {
return res.status(404).send('Book not found') return res.status(404).send('Book not found')
} }
@@ -231,14 +233,14 @@ class CollectionController {
// Check if book is already in collection // Check if book is already in collection
const collectionBooks = await req.collection.getCollectionBooks() 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') return res.status(400).send('Book already in collection')
} }
// Create collectionBook record // Create collectionBook record
await Database.collectionBookModel.create({ await Database.collectionBookModel.create({
collectionId: req.collection.id, collectionId: req.collection.id,
bookId: libraryItem.media.id, bookId: libraryItem.mediaId,
order: collectionBooks.length + 1 order: collectionBooks.length + 1
}) })
const jsonExpanded = await req.collection.getOldJsonExpanded() const jsonExpanded = await req.collection.getOldJsonExpanded()
@@ -255,7 +257,9 @@ class CollectionController {
* @param {Response} res * @param {Response} res
*/ */
async removeBook(req, 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) { if (!libraryItem) {
return res.sendStatus(404) return res.sendStatus(404)
} }
@@ -266,7 +270,7 @@ class CollectionController {
}) })
let jsonExpanded = null let jsonExpanded = null
const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.media.id) const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.mediaId)
if (collectionBookToRemove) { if (collectionBookToRemove) {
// Remove collection book record // Remove collection book record
await collectionBookToRemove.destroy() await collectionBookToRemove.destroy()
@@ -274,7 +278,7 @@ class CollectionController {
// Update order on collection books // Update order on collection books
let order = 1 let order = 1
for (const collectionBook of collectionBooks) { for (const collectionBook of collectionBooks) {
if (collectionBook.bookId === libraryItem.media.id) continue if (collectionBook.bookId === libraryItem.mediaId) continue
if (collectionBook.order !== order) { if (collectionBook.order !== order) {
await collectionBook.update({ await collectionBook.update({
order order
+1 -1
View File
@@ -106,7 +106,7 @@ class EmailController {
return res.sendStatus(403) return res.sendStatus(403)
} }
const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId) const libraryItem = await Database.libraryItemModel.getExpandedById(req.body.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
return res.status(404).send('Library item not found') return res.status(404).send('Library item not found')
} }
+6 -6
View File
@@ -1145,14 +1145,14 @@ class LibraryController {
await libraryItem.media.update({ await libraryItem.media.update({
narrators: libraryItem.media.narrators narrators: libraryItem.media.narrators
}) })
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
itemsUpdated.push(oldLibraryItem) itemsUpdated.push(libraryItem)
} }
if (itemsUpdated.length) { if (itemsUpdated.length) {
SocketAuthority.emitter( SocketAuthority.emitter(
'items_updated', 'items_updated',
itemsUpdated.map((li) => li.toJSONExpanded()) itemsUpdated.map((li) => li.toOldJSONExpanded())
) )
} }
@@ -1189,14 +1189,14 @@ class LibraryController {
await libraryItem.media.update({ await libraryItem.media.update({
narrators: libraryItem.media.narrators narrators: libraryItem.media.narrators
}) })
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
itemsUpdated.push(oldLibraryItem) itemsUpdated.push(libraryItem)
} }
if (itemsUpdated.length) { if (itemsUpdated.length) {
SocketAuthority.emitter( SocketAuthority.emitter(
'items_updated', 'items_updated',
itemsUpdated.map((li) => li.toJSONExpanded()) itemsUpdated.map((li) => li.toOldJSONExpanded())
) )
} }
+39 -19
View File
@@ -249,6 +249,9 @@ class LibraryItemController {
const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
if (hasUpdates) { if (hasUpdates) {
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
if (isPodcastAutoDownloadUpdated) { if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(req.libraryItem) this.cronManager.checkUpdatePodcastCron(req.libraryItem)
} }
@@ -548,11 +551,11 @@ class LibraryItemController {
const hardDelete = req.query.hard == 1 // Delete files from filesystem const hardDelete = req.query.hard == 1 // Delete files from filesystem
const { libraryItemIds } = req.body const { libraryItemIds } = req.body
if (!libraryItemIds?.length) { if (!libraryItemIds?.length || !Array.isArray(libraryItemIds)) {
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({ const itemsToDelete = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds id: libraryItemIds
}) })
@@ -563,19 +566,19 @@ class LibraryItemController {
const libraryId = itemsToDelete[0].libraryId const libraryId = itemsToDelete[0].libraryId
for (const libraryItem of itemsToDelete) { for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path 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 mediaItemIds = []
const seriesIds = [] const seriesIds = []
const authorIds = [] const authorIds = []
if (libraryItem.isPodcast) { if (libraryItem.isPodcast) {
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id)) mediaItemIds.push(...libraryItem.media.podcastEpisodes.map((ep) => ep.id))
} else { } else {
mediaItemIds.push(libraryItem.media.id) mediaItemIds.push(libraryItem.media.id)
if (libraryItem.media.metadata.series?.length) { if (libraryItem.media.series?.length) {
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id)) seriesIds.push(...libraryItem.media.series.map((se) => se.id))
} }
if (libraryItem.media.metadata.authors?.length) { if (libraryItem.media.authors?.length) {
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id)) authorIds.push(...libraryItem.media.authors.map((au) => au.id))
} }
} }
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
@@ -620,7 +623,7 @@ class LibraryItemController {
} }
// Get all library items to update // Get all library items to update
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds id: libraryItemIds
}) })
if (updatePayloads.length !== libraryItems.length) { if (updatePayloads.length !== libraryItems.length) {
@@ -642,21 +645,23 @@ class LibraryItemController {
if (libraryItem.isBook) { if (libraryItem.isBook) {
if (Array.isArray(mediaPayload.metadata?.series)) { if (Array.isArray(mediaPayload.metadata?.series)) {
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id) const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) const seriesRemoved = libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id)) seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
} }
if (Array.isArray(mediaPayload.metadata?.authors)) { if (Array.isArray(mediaPayload.metadata?.authors)) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) const authorsRemoved = libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id)) authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
} }
} }
if (libraryItem.media.update(mediaPayload)) { const hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) if (hasUpdates) {
libraryItem.changed('updatedAt', true)
await libraryItem.save()
await Database.updateLibraryItem(libraryItem) Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
itemsUpdated++ itemsUpdated++
} }
} }
@@ -685,11 +690,11 @@ class LibraryItemController {
if (!libraryItemIds.length) { if (!libraryItemIds.length) {
return res.status(403).send('Invalid payload') return res.status(403).send('Invalid payload')
} }
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds id: libraryItemIds
}) })
res.json({ res.json({
libraryItems: libraryItems.map((li) => li.toJSONExpanded()) libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
}) })
} }
@@ -712,7 +717,7 @@ class LibraryItemController {
return res.sendStatus(400) return res.sendStatus(400)
} }
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: req.body.libraryItemIds id: req.body.libraryItemIds
}) })
if (!libraryItems?.length) { if (!libraryItems?.length) {
@@ -734,7 +739,8 @@ class LibraryItemController {
} }
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options) const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
const matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options)
if (matchResult.updated) { if (matchResult.updated) {
itemsUpdated++ itemsUpdated++
} else if (matchResult.warning) { } else if (matchResult.warning) {
@@ -971,6 +977,20 @@ class LibraryItemController {
} }
} else if (req.libraryItem.media.podcastEpisodes.some((ep) => ep.audioFile.ino === req.params.fileid)) { } 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) 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() await episodeToRemove.destroy()
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid) req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid)
+7 -7
View File
@@ -66,7 +66,7 @@ class MeController {
const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId) const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId) 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}"`) Logger.error(`[MeController] Media item not found for library item id "${req.params.libraryItemId}"`)
return res.sendStatus(404) 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 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 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 = [] let itemsInProgress = []
@@ -304,19 +304,19 @@ class MeController {
const oldMediaProgress = mediaProgress.getOldMediaProgress() const oldMediaProgress = mediaProgress.getOldMediaProgress()
const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId) const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId)
if (libraryItem) { if (libraryItem) {
if (oldMediaProgress.episodeId && libraryItem.mediaType === 'podcast') { if (oldMediaProgress.episodeId && libraryItem.isPodcast) {
const episode = libraryItem.media.episodes.find((ep) => ep.id === oldMediaProgress.episodeId) const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === oldMediaProgress.episodeId)
if (episode) { if (episode) {
const libraryItemWithEpisode = { const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(), ...libraryItem.toOldJSONMinified(),
recentEpisode: episode.toJSON(), recentEpisode: episode.toOldJSON(libraryItem.id),
progressLastUpdate: oldMediaProgress.lastUpdate progressLastUpdate: oldMediaProgress.lastUpdate
} }
itemsInProgress.push(libraryItemWithEpisode) itemsInProgress.push(libraryItemWithEpisode)
} }
} else if (!oldMediaProgress.episodeId) { } else if (!oldMediaProgress.episodeId) {
itemsInProgress.push({ itemsInProgress.push({
...libraryItem.toJSONMinified(), ...libraryItem.toOldJSONMinified(),
progressLastUpdate: oldMediaProgress.lastUpdate progressLastUpdate: oldMediaProgress.lastUpdate
}) })
} }
+8 -8
View File
@@ -342,8 +342,8 @@ class MiscController {
tags: libraryItem.media.tags tags: libraryItem.media.tags
}) })
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++ numItemsUpdated++
} }
} }
@@ -385,8 +385,8 @@ class MiscController {
tags: libraryItem.media.tags tags: libraryItem.media.tags
}) })
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++ numItemsUpdated++
} }
@@ -480,8 +480,8 @@ class MiscController {
genres: libraryItem.media.genres genres: libraryItem.media.genres
}) })
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++ numItemsUpdated++
} }
} }
@@ -523,8 +523,8 @@ class MiscController {
genres: libraryItem.media.genres genres: libraryItem.media.genres
}) })
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++ numItemsUpdated++
} }
+13 -13
View File
@@ -276,7 +276,7 @@ class PlaylistController {
return res.status(400).send('Request body has no libraryItemId') return res.status(400).send('Request body has no libraryItemId')
} }
const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId) const libraryItem = await Database.libraryItemModel.getExpandedById(itemToAdd.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
return res.status(400).send('Library item not found') return res.status(400).send('Library item not found')
} }
@@ -286,7 +286,7 @@ class PlaylistController {
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) { if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
return res.status(400).send('Invalid item to add for this library type') 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') 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 // Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
if (itemToAdd.episodeId) { 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({ jsonExpanded.items.push({
episodeId: itemToAdd.episodeId, episodeId: itemToAdd.episodeId,
episode: episode.toJSONExpanded(), episode: episode.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONMinified() libraryItem: libraryItem.toOldJSONMinified()
}) })
} else { } else {
jsonExpanded.items.push({ jsonExpanded.items.push({
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONExpanded() libraryItem: libraryItem.toOldJSONExpanded()
}) })
} }
@@ -388,8 +388,8 @@ class PlaylistController {
// Find all library items // Find all library items
const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i)) const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
const oldLibraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: Array.from(libraryItemIds) }) const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) })
if (oldLibraryItems.length !== libraryItemIds.size) { if (libraryItems.length !== libraryItemIds.size) {
return res.status(400).send('Invalid request body items') return res.status(400).send('Invalid request body items')
} }
@@ -401,7 +401,7 @@ class PlaylistController {
// Setup array of playlistMediaItem records to add // Setup array of playlistMediaItem records to add
let order = req.playlist.playlistMediaItems.length + 1 let order = req.playlist.playlistMediaItems.length + 1
for (const item of req.body.items) { 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 const mediaItemId = item.episodeId || libraryItem.media.id
if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) { 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 // Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
if (item.episodeId) { 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({ jsonExpanded.items.push({
episodeId: item.episodeId, episodeId: item.episodeId,
episode: episode.toJSONExpanded(), episode: episode.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONMinified() libraryItem: libraryItem.toOldJSONMinified()
}) })
} else { } else {
jsonExpanded.items.push({ jsonExpanded.items.push({
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
libraryItem: libraryItem.toJSONExpanded() libraryItem: libraryItem.toOldJSONExpanded()
}) })
} }
} }
+156 -108
View File
@@ -1,3 +1,4 @@
const Path = require('path')
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
@@ -12,13 +13,16 @@ const { validateUrl } = require('../utils/index')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const CoverManager = require('../managers/CoverManager') const CoverManager = require('../managers/CoverManager')
const LibraryItem = require('../objects/LibraryItem')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
*/ */
class PodcastController { class PodcastController {
@@ -37,6 +41,9 @@ class PodcastController {
return res.sendStatus(403) return res.sendStatus(403)
} }
const payload = req.body 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) const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
if (!library) { if (!library) {
@@ -78,48 +85,87 @@ class PodcastController {
let relPath = payload.path.replace(folder.fullPath, '') let relPath = payload.path.replace(folder.fullPath, '')
if (relPath.startsWith('/')) relPath = relPath.slice(1) if (relPath.startsWith('/')) relPath = relPath.slice(1)
const libraryItemPayload = { let newLibraryItem = null
path: podcastPath, const transaction = await Database.sequelize.transaction()
relPath, try {
folderId: payload.folderId, const podcast = await Database.podcastModel.createFromRequest(payload.media, transaction)
libraryId: payload.libraryId,
ino: libraryItemFolderStats.ino, newLibraryItem = await Database.libraryItemModel.create(
mtimeMs: libraryItemFolderStats.mtimeMs || 0, {
ctimeMs: libraryItemFolderStats.ctimeMs || 0, ino: libraryItemFolderStats.ino,
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, path: podcastPath,
media: payload.media 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() newLibraryItem.media = await newLibraryItem.getMediaExpanded()
libraryItem.setData('podcast', libraryItemPayload)
// Download and save cover image // Download and save cover image
if (payload.media.metadata.imageUrl) { if (typeof payload.media.metadata.imageUrl === 'string' && payload.media.metadata.imageUrl.startsWith('http')) {
// TODO: Scan cover image to library files
// Podcast cover will always go into library item folder // Podcast cover will always go into library item folder
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true) const coverResponse = await CoverManager.downloadCoverFromUrlNew(payload.media.metadata.imageUrl, newLibraryItem.id, newLibraryItem.path, true)
if (coverResponse) { if (coverResponse.error) {
if (coverResponse.error) { Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`) } else if (coverResponse.cover) {
} else if (coverResponse.cover) { const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
libraryItem.media.coverPath = 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', newLibraryItem.toOldJSONExpanded())
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSONExpanded()) res.json(newLibraryItem.toOldJSONExpanded())
if (payload.episodesToDownload?.length) {
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
}
// Turn on podcast auto download cron if not already on // Turn on podcast auto download cron if not already on
if (libraryItem.media.autoDownloadEpisodes) { if (newLibraryItem.media.autoDownloadEpisodes) {
this.cronManager.checkUpdatePodcastCron(libraryItem) this.cronManager.checkUpdatePodcastCron(newLibraryItem)
} }
} }
@@ -213,7 +259,7 @@ class PodcastController {
* *
* @this import('../routers/ApiRouter') * @this import('../routers/ApiRouter')
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async checkNewEpisodes(req, res) { async checkNewEpisodes(req, res) {
@@ -222,15 +268,14 @@ class PodcastController {
return res.sendStatus(403) return res.sendStatus(403)
} }
var libraryItem = req.libraryItem if (!req.libraryItem.media.feedURL) {
if (!libraryItem.media.metadata.feedUrl) { Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`)
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`) return res.status(400).send('Podcast has no rss feed url')
return res.status(500).send('Podcast has no rss feed url')
} }
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3 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({ res.json({
episodes: newEpisodes || [] episodes: newEpisodes || []
}) })
@@ -258,23 +303,28 @@ class PodcastController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
getEpisodeDownloads(req, res) { getEpisodeDownloads(req, res) {
var libraryItem = req.libraryItem const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
res.json({ res.json({
downloads: downloadsInQueue.map((d) => d.toJSONForClient()) 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) { async findEpisode(req, res) {
const rssFeedUrl = req.libraryItem.media.metadata.feedUrl const rssFeedUrl = req.libraryItem.media.feedURL
if (!rssFeedUrl) { if (!rssFeedUrl) {
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`) 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 const searchTitle = req.query.title
@@ -292,7 +342,7 @@ class PodcastController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async downloadEpisodes(req, res) { async downloadEpisodes(req, res) {
@@ -300,13 +350,13 @@ class PodcastController {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`) Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
return res.sendStatus(403) return res.sendStatus(403)
} }
const libraryItem = req.libraryItem
const episodes = req.body const episodes = req.body
if (!episodes?.length) { if (!Array.isArray(episodes) || !episodes.length) {
return res.sendStatus(400) return res.sendStatus(400)
} }
this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes) this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes)
res.sendStatus(200) res.sendStatus(200)
} }
@@ -315,7 +365,7 @@ class PodcastController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async quickMatchEpisodes(req, res) { async quickMatchEpisodes(req, res) {
@@ -325,10 +375,11 @@ class PodcastController {
} }
const overrideDetails = req.query.override === '1' const overrideDetails = req.query.override === '1'
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails }) const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem)
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(oldLibraryItem, { overrideDetails })
if (episodesUpdated) { if (episodesUpdated) {
await Database.updateLibraryItem(req.libraryItem) await Database.updateLibraryItem(oldLibraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
} }
res.json({ res.json({
@@ -339,58 +390,76 @@ class PodcastController {
/** /**
* PATCH: /api/podcasts/:id/episode/:episodeId * PATCH: /api/podcasts/:id/episode/:episodeId
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async updateEpisode(req, res) { async updateEpisode(req, res) {
const libraryItem = req.libraryItem /** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId)
var episodeId = req.params.episodeId if (!episode) {
if (!libraryItem.media.checkHasEpisode(episodeId)) {
return res.status(404).send('Episode not found') return res.status(404).send('Episode not found')
} }
if (libraryItem.media.updateEpisode(episodeId, req.body)) { const updatePayload = {}
await Database.updateLibraryItem(libraryItem) const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType']
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) 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 * GET: /api/podcasts/:id/episode/:episodeId
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async getEpisode(req, res) { async getEpisode(req, res) {
const episodeId = req.params.episodeId 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) { 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) return res.sendStatus(404)
} }
res.json(episode) res.json(episode.toOldJSON(req.libraryItem.id))
} }
/** /**
* DELETE: /api/podcasts/:id/episode/:episodeId * DELETE: /api/podcasts/:id/episode/:episodeId
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async removeEpisode(req, res) { async removeEpisode(req, res) {
const episodeId = req.params.episodeId const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const hardDelete = req.query.hard === '1' 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) { 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) return res.sendStatus(404)
} }
@@ -407,36 +476,8 @@ class PodcastController {
}) })
} }
// Remove episode from Podcast and library file // Remove episode from playlists
const episodeRemoved = libraryItem.media.removeEpisode(episodeId) await Database.playlistModel.removeMediaItemsFromPlaylists([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 media progress for this episode // Remove media progress for this episode
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({ const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
@@ -448,9 +489,16 @@ class PodcastController {
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`) Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
} }
await Database.updateLibraryItem(libraryItem) // Remove episode
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) await episode.destroy()
res.json(libraryItem.toJSON())
// 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 +508,15 @@ class PodcastController {
* @param {NextFunction} next * @param {NextFunction} next
*/ */
async middleware(req, res, next) { async middleware(req, res, next) {
const item = await Database.libraryItemModel.getOldById(req.params.id) const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!item?.media) return res.sendStatus(404) if (!libraryItem?.media) return res.sendStatus(404)
if (!item.isPodcast) { if (!libraryItem.isPodcast) {
return res.sendStatus(500) return res.sendStatus(500)
} }
// Check user can access this library item // Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) { if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403) return res.sendStatus(403)
} }
@@ -480,7 +528,7 @@ class PodcastController {
return res.sendStatus(403) return res.sendStatus(403)
} }
req.libraryItem = item req.libraryItem = libraryItem
next() next()
} }
} }
+1 -1
View File
@@ -149,7 +149,7 @@ class SessionController {
* @param {Response} res * @param {Response} res
*/ */
async getOpenSession(req, 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) const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
res.json(sessionForClient) res.json(sessionForClient)
} }
+6 -7
View File
@@ -70,14 +70,13 @@ class ShareController {
} }
try { try {
const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType) const libraryItem = await Database.mediaItemShareModel.getMediaItemsLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
if (!libraryItem) {
if (!oldLibraryItem) {
return res.status(404).send('Media item not found') return res.status(404).send('Media item not found')
} }
let startOffset = 0 let startOffset = 0
const publicTracks = oldLibraryItem.media.includedAudioFiles.map((audioFile) => { const publicTracks = libraryItem.media.includedAudioFiles.map((audioFile) => {
const audioTrack = { const audioTrack = {
index: audioFile.index, index: audioFile.index,
startOffset, startOffset,
@@ -86,7 +85,7 @@ class ShareController {
contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`, contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`,
mimeType: audioFile.mimeType, mimeType: audioFile.mimeType,
codec: audioFile.codec || null, codec: audioFile.codec || null,
metadata: audioFile.metadata.clone() metadata: structuredClone(audioFile.metadata)
} }
startOffset += audioTrack.duration startOffset += audioTrack.duration
return audioTrack return audioTrack
@@ -105,12 +104,12 @@ class ShareController {
const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo) const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo)
const newPlaybackSession = new PlaybackSession() const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(oldLibraryItem, null, 'web-share', deviceInfo, startTime) newPlaybackSession.setData(libraryItem, null, 'web-share', deviceInfo, startTime)
newPlaybackSession.audioTracks = publicTracks newPlaybackSession.audioTracks = publicTracks
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
newPlaybackSession.shareSessionId = shareSessionId newPlaybackSession.shareSessionId = shareSessionId
newPlaybackSession.mediaItemShareId = mediaItemShare.id newPlaybackSession.mediaItemShareId = mediaItemShare.id
newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio newPlaybackSession.coverAspectRatio = libraryItem.library.settings.coverAspectRatio
mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient() mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient()
ShareManager.addOpenSharePlaybackSession(newPlaybackSession) ShareManager.addOpenSharePlaybackSession(newPlaybackSession)
+4 -3
View File
@@ -338,13 +338,14 @@ class CoverManager {
* *
* @param {string} url * @param {string} url
* @param {string} libraryItemId * @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}>} * @returns {Promise<{error:string}|{cover:string}>}
*/ */
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) { async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath, forceLibraryItemFolder = false) {
try { try {
let coverDirPath = null let coverDirPath = null
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) { if ((global.ServerSettings.storeCoverWithItem || forceLibraryItemFolder) && libraryItemPath) {
coverDirPath = libraryItemPath coverDirPath = libraryItemPath
} else { } else {
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId) coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
+2 -2
View File
@@ -181,7 +181,7 @@ class CronManager {
// Get podcast library items to check // Get podcast library items to check
const libraryItems = [] const libraryItems = []
for (const libraryItemId of libraryItemIds) { for (const libraryItemId of libraryItemIds) {
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId) const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`) Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out
@@ -217,7 +217,7 @@ class CronManager {
/** /**
* *
* @param {import('../models/LibraryItem')} libraryItem - this can be the old model * @param {import('../models/LibraryItem')} libraryItem
*/ */
checkUpdatePodcastCron(libraryItem) { checkUpdatePodcastCron(libraryItem) {
// Remove from old cron by library item id // Remove from old cron by library item id
+10 -5
View File
@@ -14,6 +14,11 @@ class NotificationManager {
return notificationData return notificationData
} }
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../models/PodcastEpisode')} episode
*/
async onPodcastEpisodeDownloaded(libraryItem, episode) { async onPodcastEpisodeDownloaded(libraryItem, episode) {
if (!Database.notificationSettings.isUseable) return if (!Database.notificationSettings.isUseable) return
@@ -22,17 +27,17 @@ class NotificationManager {
return 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 library = await Database.libraryModel.findByPk(libraryItem.libraryId)
const eventData = { const eventData = {
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId, libraryId: libraryItem.libraryId,
libraryName: library?.name || 'Unknown', libraryName: library?.name || 'Unknown',
mediaTags: (libraryItem.media.tags || []).join(', '), mediaTags: (libraryItem.media.tags || []).join(', '),
podcastTitle: libraryItem.media.metadata.title, podcastTitle: libraryItem.media.title,
podcastAuthor: libraryItem.media.metadata.author || '', podcastAuthor: libraryItem.media.author || '',
podcastDescription: libraryItem.media.metadata.description || '', podcastDescription: libraryItem.media.description || '',
podcastGenres: (libraryItem.media.metadata.genres || []).join(', '), podcastGenres: (libraryItem.media.genres || []).join(', '),
episodeId: episode.id, episodeId: episode.id,
episodeTitle: episode.title, episodeTitle: episode.title,
episodeSubtitle: episode.subtitle || '', episodeSubtitle: episode.subtitle || '',
+10 -12
View File
@@ -74,7 +74,7 @@ class PlaybackSessionManager {
async startSessionRequest(req, res, episodeId) { async startSessionRequest(req, res, episodeId) {
const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo) const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`) Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`)
const { oldLibraryItem: libraryItem, body: options } = req const { libraryItem, body: options } = req
const session = await this.startSession(req.user, deviceInfo, libraryItem, episodeId, options) const session = await this.startSession(req.user, deviceInfo, libraryItem, episodeId, options)
res.json(session.toJSONForClient(libraryItem)) res.json(session.toJSONForClient(libraryItem))
} }
@@ -120,8 +120,8 @@ class PlaybackSessionManager {
*/ */
async syncLocalSession(user, sessionJson, deviceInfo) { async syncLocalSession(user, sessionJson, deviceInfo) {
// TODO: Combine libraryItem query with library query // TODO: Combine libraryItem query with library query
const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId) const libraryItem = await Database.libraryItemModel.getExpandedById(sessionJson.libraryItemId)
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.getEpisode(sessionJson.episodeId) : null const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.podcastEpisodes.find((pe) => pe.id === sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) { if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`) Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
return { return {
@@ -175,7 +175,8 @@ class PlaybackSessionManager {
// New session from local // New session from local
session = new PlaybackSession(sessionJson) session = new PlaybackSession(sessionJson)
session.deviceInfo = deviceInfo 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})`) Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
await Database.createPlaybackSession(session) await Database.createPlaybackSession(session)
} else { } else {
@@ -279,7 +280,7 @@ class PlaybackSessionManager {
* *
* @param {import('../models/User')} user * @param {import('../models/User')} user
* @param {DeviceInfo} deviceInfo * @param {DeviceInfo} deviceInfo
* @param {import('../objects/LibraryItem')} libraryItem * @param {import('../models/LibraryItem')} libraryItem
* @param {string|null} episodeId * @param {string|null} episodeId
* @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options * @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options
* @returns {Promise<PlaybackSession>} * @returns {Promise<PlaybackSession>}
@@ -292,7 +293,7 @@ class PlaybackSessionManager {
await this.closeSession(user, session, null) 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 mediaPlayer = options.mediaPlayer || 'unknown'
const mediaItemId = episodeId || libraryItem.media.id const mediaItemId = episodeId || libraryItem.media.id
@@ -300,7 +301,7 @@ class PlaybackSessionManager {
let userStartTime = 0 let userStartTime = 0
if (userProgress) { if (userProgress) {
if (userProgress.isFinished) { 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 // Keep userStartTime as 0 so the client restarts the media
} else { } else {
userStartTime = Number.parseFloat(userProgress.currentTime) || 0 userStartTime = Number.parseFloat(userProgress.currentTime) || 0
@@ -312,7 +313,7 @@ class PlaybackSessionManager {
let audioTracks = [] let audioTracks = []
if (shouldDirectPlay) { if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`) 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 newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else { } else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`) Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
@@ -346,7 +347,7 @@ class PlaybackSessionManager {
*/ */
async syncSession(user, session, syncData) { async syncSession(user, session, syncData) {
// TODO: Combine libraryItem query with library query // 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) { if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`) Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null return null
@@ -381,9 +382,6 @@ class PlaybackSessionManager {
}) })
} }
this.saveSession(session) this.saveSession(session)
return {
libraryItem
}
} }
/** /**
+241 -133
View File
@@ -1,3 +1,4 @@
const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
@@ -19,9 +20,7 @@ const NotificationManager = require('../managers/NotificationManager')
const LibraryFile = require('../objects/files/LibraryFile') const LibraryFile = require('../objects/files/LibraryFile')
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
const AudioFile = require('../objects/files/AudioFile') const AudioFile = require('../objects/files/AudioFile')
const LibraryItem = require('../objects/LibraryItem')
class PodcastManager { class PodcastManager {
constructor() { 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) { 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) { 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() const newPeDl = new PodcastEpisodeDownload()
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId) newPeDl.setData(ep, libraryItem, isAutoDownload, libraryItem.libraryId)
this.startPodcastEpisodeDownload(newPeDl) this.startPodcastEpisodeDownload(newPeDl)
} }
} }
@@ -86,20 +86,20 @@ class PodcastManager {
key: 'MessageDownloadingEpisode' key: 'MessageDownloadingEpisode'
} }
const taskDescriptionString = { const taskDescriptionString = {
text: `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`, text: `Downloading episode "${podcastEpisodeDownload.episodeTitle}".`,
key: 'MessageTaskDownloadingEpisodeDescription', key: 'MessageTaskDownloadingEpisodeDescription',
subs: [podcastEpisodeDownload.podcastEpisode.title] subs: [podcastEpisodeDownload.episodeTitle]
} }
const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData) const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData)
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload 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" // 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) // this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
if (await fs.pathExists(this.currentDownload.targetPath)) { if (await fs.pathExists(this.currentDownload.targetPath)) {
this.currentDownload.appendEpisodeId = true this.currentDownload.appendRandomId = true
} }
// Ignores all added files to this dir // Ignores all added files to this dir
@@ -140,7 +140,7 @@ class PodcastManager {
} }
task.setFailed(taskFailedString) task.setFailed(taskFailedString)
} else { } 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) this.currentDownload.setFinished(true)
task.setFinished() task.setFinished()
} }
@@ -166,47 +166,61 @@ class PodcastManager {
} }
} }
/**
* Scans the downloaded audio file, create the podcast episode, remove oldest episode if necessary
* @returns {Promise<boolean>} - Returns true if added
*/
async scanAddPodcastEpisodeAudioFile() { 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) const audioFile = await this.probeAudioFile(libraryFile)
if (!audioFile) { if (!audioFile) {
return false return false
} }
const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id) const libraryItem = await Database.libraryItemModel.getExpandedById(this.currentDownload.libraryItem.id)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`) Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
return false return false
} }
const podcastEpisode = this.currentDownload.podcastEpisode const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile)
podcastEpisode.audioFile = audioFile
if (audioFile.chapters?.length) { libraryItem.libraryFiles.push(libraryFile.toJSON())
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch })) libraryItem.changed('libraryFiles', true)
}
libraryItem.media.addPodcastEpisode(podcastEpisode) libraryItem.media.podcastEpisodes.push(podcastEpisode)
if (libraryItem.isInvalid) {
// First episode added to an empty podcast
libraryItem.isInvalid = false
}
libraryItem.libraryFiles.push(libraryFile)
if (this.currentDownload.isAutoDownload) { if (this.currentDownload.isAutoDownload) {
// Check setting maxEpisodesToKeep and remove episode if necessary // Check setting maxEpisodesToKeep and remove episode if necessary
if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) { const numEpisodesWithPubDate = libraryItem.media.podcastEpisodes.filter((ep) => !!ep.publishedAt).length
Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`) if (libraryItem.media.maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem.media.maxEpisodesToKeep) {
await this.removeOldestEpisode(libraryItem, podcastEpisode.id) 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 libraryItem.save()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded() const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded() podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
SocketAuthority.emitter('episode_added', podcastEpisodeExpanded) SocketAuthority.emitter('episode_added', podcastEpisodeExpanded)
if (this.currentDownload.isAutoDownload) { if (this.currentDownload.isAutoDownload) {
@@ -217,45 +231,53 @@ class PodcastManager {
return true return true
} }
async removeOldestEpisode(libraryItem, episodeIdJustDownloaded) { /**
var smallestPublishedAt = 0 * Find oldest episode publishedAt and delete the audio file
var oldestEpisode = null *
libraryItem.media.episodesWithPubDate * @param {import('../models/LibraryItem').LibraryItemExpanded} libraryItem
.filter((ep) => ep.id !== episodeIdJustDownloaded) * @param {string} episodeIdJustDownloaded
.forEach((ep) => { * @returns {Promise<import('../models/PodcastEpisode')|null>} - Returns the episode to remove
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) { */
smallestPublishedAt = ep.publishedAt async getRemoveOldestEpisode(libraryItem, episodeIdJustDownloaded) {
oldestEpisode = ep let smallestPublishedAt = 0
} /** @type {import('../models/PodcastEpisode')} */
}) let oldestEpisode = null
// TODO: Should we check for open playback sessions for this episode?
// TODO: remove all user progress for this episode /** @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) { if (oldestEpisode?.audioFile) {
Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`) Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`)
const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path) const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path)
if (successfullyDeleted) { if (successfullyDeleted) {
libraryItem.media.removeEpisode(oldestEpisode.id) return oldestEpisode
libraryItem.removeLibraryFile(oldestEpisode.audioFile.ino)
return true
} else { } else {
Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`) Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`)
} }
} }
return false return null
}
async getLibraryFile(path, relPath) {
var newLibFile = new LibraryFile()
await newLibFile.setDataFromPath(path, relPath)
return newLibFile
} }
/**
*
* @param {LibraryFile} libraryFile
* @returns {Promise<AudioFile|null>}
*/
async probeAudioFile(libraryFile) { async probeAudioFile(libraryFile) {
const path = libraryFile.metadata.path const path = libraryFile.metadata.path
const mediaProbeData = await prober.probe(path) const mediaProbeData = await prober.probe(path)
if (mediaProbeData.error) { if (mediaProbeData.error) {
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error) Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
return false return null
} }
const newAudioFile = new AudioFile() const newAudioFile = new AudioFile()
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData) newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
@@ -263,18 +285,23 @@ class PodcastManager {
return newAudioFile 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) { async runEpisodeCheck(libraryItem) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0) const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished const latestEpisodePublishedAt = libraryItem.media.getLatestEpisodePublishedAt()
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" | Last check: ${new Date(lastEpisodeCheck)} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
// 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)}`)
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`) Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`)
if (!newEpisodes) { if (!newEpisodes) {
@@ -283,37 +310,48 @@ class PodcastManager {
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0 if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
this.failedCheckMap[libraryItem.id]++ this.failedCheckMap[libraryItem.id]++
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) { 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 libraryItem.media.autoDownloadEpisodes = false
delete this.failedCheckMap[libraryItem.id] delete this.failedCheckMap[libraryItem.id]
} else { } 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) { } else if (newEpisodes.length) {
delete this.failedCheckMap[libraryItem.id] 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) this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)
} else { } else {
delete this.failedCheckMap[libraryItem.id] 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.media.lastEpisodeCheck = new Date()
libraryItem.updatedAt = Date.now() await libraryItem.media.save()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) libraryItem.changed('updatedAt', true)
await libraryItem.save()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
return libraryItem.media.autoDownloadEpisodes 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) { async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {
if (!podcastLibraryItem.media.metadata.feedUrl) { if (!podcastLibraryItem.media.feedURL) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`) Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
return false return null
} }
const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl) const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
if (!feed?.episodes) { if (!feed?.episodes) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed) Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
return false return null
} }
// Filter new and not already has // Filter new and not already has
@@ -326,23 +364,34 @@ class PodcastManager {
return newEpisodes return newEpisodes
} }
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} maxEpisodesToDownload
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]>}
*/
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) { async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0) const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`) const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never'
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload) Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`)
if (newEpisodes.length) {
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`) 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) this.downloadPodcastEpisodes(libraryItem, newEpisodes, false)
} else { } 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.media.lastEpisodeCheck = new Date()
libraryItem.updatedAt = Date.now() await libraryItem.media.save()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
return newEpisodes libraryItem.changed('updatedAt', true)
await libraryItem.save()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
return newEpisodes || []
} }
async findEpisode(rssFeedUrl, searchTitle) { async findEpisode(rssFeedUrl, searchTitle) {
@@ -518,64 +567,123 @@ class PodcastManager {
continue continue
} }
const newPodcastMetadata = { let newLibraryItem = null
title: feed.metadata.title, const transaction = await Database.sequelize.transaction()
author: feed.metadata.author, try {
description: feed.metadata.description, const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
releaseDate: '',
genres: [...feed.metadata.categories],
feedUrl: feed.metadata.feedUrl,
imageUrl: feed.metadata.image,
itunesPageUrl: '',
itunesId: '',
itunesArtistId: '',
language: '',
numEpisodes: feed.numEpisodes
}
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) const podcastPayload = {
const libraryItemPayload = { autoDownloadEpisodes,
path: podcastPath, metadata: {
relPath: podcastFilename, title: feed.metadata.title,
folderId: folder.id, author: feed.metadata.author,
libraryId: folder.libraryId, description: feed.metadata.description,
ino: libraryItemFolderStats.ino, releaseDate: '',
mtimeMs: libraryItemFolderStats.mtimeMs || 0, genres: [...feed.metadata.categories],
ctimeMs: libraryItemFolderStats.ctimeMs || 0, feedUrl: feed.metadata.feedUrl,
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, imageUrl: feed.metadata.image,
media: { itunesPageUrl: '',
metadata: newPodcastMetadata, itunesId: '',
autoDownloadEpisodes 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() newLibraryItem.media = await newLibraryItem.getMediaExpanded()
libraryItem.setData('podcast', libraryItemPayload)
// Download and save cover image // Download and save cover image
if (newPodcastMetadata.imageUrl) { if (typeof feed.metadata.image === 'string' && feed.metadata.image.startsWith('http')) {
// TODO: Scan cover image to library files
// Podcast cover will always go into library item folder // Podcast cover will always go into library item folder
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true) const coverResponse = await CoverManager.downloadCoverFromUrlNew(feed.metadata.image, newLibraryItem.id, newLibraryItem.path, true)
if (coverResponse) { if (coverResponse.error) {
if (coverResponse.error) { Logger.error(`[PodcastManager] Download cover error from "${feed.metadata.image}": ${coverResponse.error}`)
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`) } else if (coverResponse.cover) {
} else if (coverResponse.cover) { const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
libraryItem.media.coverPath = 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', newLibraryItem.toOldJSONExpanded())
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
// Turn on podcast auto download cron if not already on // Turn on podcast auto download cron if not already on
if (libraryItem.media.autoDownloadEpisodes) { if (newLibraryItem.media.autoDownloadEpisodes) {
cronManager.checkUpdatePodcastCron(libraryItem) cronManager.checkUpdatePodcastCron(newLibraryItem)
} }
numPodcastsAdded++ numPodcastsAdded++
} }
const taskFinishedString = { const taskFinishedString = {
text: `Added ${numPodcastsAdded} podcasts`, text: `Added ${numPodcastsAdded} podcasts`,
key: 'MessageTaskOpmlImportFinished', key: 'MessageTaskOpmlImportFinished',
+62 -12
View File
@@ -62,6 +62,13 @@ const parseNameString = require('../utils/parsers/parseNameString')
* @property {ChapterObject[]} chapters * @property {ChapterObject[]} chapters
* @property {Object} metaTags * @property {Object} metaTags
* @property {string} mimeType * @property {string} mimeType
*
* @typedef AudioTrackProperties
* @property {string} title
* @property {string} contentUrl
* @property {number} startOffset
*
* @typedef {AudioFileObject & AudioTrackProperties} AudioTrack
*/ */
class Book extends Model { class Book extends Model {
@@ -367,16 +374,6 @@ class Book extends Model {
return this.audioFiles.filter((af) => !af.exclude) return this.audioFiles.filter((af) => !af.exclude)
} }
get trackList() {
let startOffset = 0
return this.includedAudioFiles.map((af) => {
const track = structuredClone(af)
track.startOffset = startOffset
startOffset += track.duration
return track
})
}
get hasMediaFiles() { get hasMediaFiles() {
return !!this.hasAudioTracks || !!this.ebookFile return !!this.hasAudioTracks || !!this.ebookFile
} }
@@ -385,6 +382,59 @@ class Book extends Model {
return !!this.includedAudioFiles.length 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 * Total file size of all audio files and ebook file
* *
@@ -635,7 +685,7 @@ class Book extends Model {
metadata: this.oldMetadataToJSONMinified(), metadata: this.oldMetadataToJSONMinified(),
coverPath: this.coverPath, coverPath: this.coverPath,
tags: [...(this.tags || [])], tags: [...(this.tags || [])],
numTracks: this.trackList.length, numTracks: this.includedAudioFiles.length,
numAudioFiles: this.audioFiles?.length || 0, numAudioFiles: this.audioFiles?.length || 0,
numChapters: this.chapters?.length || 0, numChapters: this.chapters?.length || 0,
duration: this.duration, duration: this.duration,
@@ -666,7 +716,7 @@ class Book extends Model {
ebookFile: structuredClone(this.ebookFile), ebookFile: structuredClone(this.ebookFile),
duration: this.duration, duration: this.duration,
size: this.size, size: this.size,
tracks: structuredClone(this.trackList) tracks: this.getTracklist(libraryItemId)
} }
} }
} }
+1 -1
View File
@@ -282,7 +282,7 @@ class Collection extends Model {
const libraryItem = book.libraryItem const libraryItem = book.libraryItem
delete book.libraryItem delete book.libraryItem
libraryItem.media = book libraryItem.media = book
return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded() return libraryItem.toOldJSONExpanded()
}) })
return json return json
+13 -11
View File
@@ -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 * 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 * @param {import('./Book')} book
* @returns {boolean} * @returns {boolean}
*/ */
static checkUseChapterTitlesForEpisodes(book) { static checkUseChapterTitlesForEpisodes(trackList, book) {
const tracks = book.trackList || []
const chapters = book.chapters || [] const chapters = book.chapters || []
if (tracks.length !== chapters.length) return false if (trackList.length !== chapters.length) return false
for (let i = 0; i < tracks.length; i++) { for (let i = 0; i < trackList.length; i++) {
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) { if (Math.abs(chapters[i].start - trackList[i].startOffset) >= 1) {
return false return false
} }
} }
@@ -148,7 +148,7 @@ class FeedEpisode extends Model {
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}` const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
let title = Path.basename(audioTrack.metadata.filename, 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 // If audiobook is a single file, use book title instead of chapter/file title
title = book.title title = book.title
} else { } else {
@@ -185,11 +185,12 @@ class FeedEpisode extends Model {
* @returns {Promise<FeedEpisode[]>} * @returns {Promise<FeedEpisode[]>}
*/ */
static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) { 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 = [] const feedEpisodeObjs = []
let numExisting = 0 let numExisting = 0
for (const track of libraryItemExpanded.media.trackList) { for (const track of trackList) {
// Check for existing episode by filepath // Check for existing episode by filepath
const existingEpisode = feed.feedEpisodes?.find((episode) => { const existingEpisode = feed.feedEpisodes?.find((episode) => {
return episode.filePath === track.metadata.path return episode.filePath === track.metadata.path
@@ -204,7 +205,7 @@ class FeedEpisode extends Model {
/** /**
* *
* @param {import('./Book')[]} books * @param {import('./Book').BookExpandedWithLibraryItem[]} books
* @param {import('./Feed')} feed * @param {import('./Feed')} feed
* @param {string} slug * @param {string} slug
* @param {import('sequelize').Transaction} transaction * @param {import('sequelize').Transaction} transaction
@@ -218,8 +219,9 @@ class FeedEpisode extends Model {
const feedEpisodeObjs = [] const feedEpisodeObjs = []
let numExisting = 0 let numExisting = 0
for (const book of books) { for (const book of books) {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book) const trackList = book.libraryItem.getTrackList()
for (const track of book.trackList) { const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, book)
for (const track of trackList) {
// Check for existing episode by filepath // Check for existing episode by filepath
const existingEpisode = feed.feedEpisodes?.find((episode) => { const existingEpisode = feed.feedEpisodes?.find((episode) => {
return episode.filePath === track.metadata.path return episode.filePath === track.metadata.path
+111 -86
View File
@@ -122,45 +122,6 @@ class LibraryItem extends Model {
}) })
} }
/**
*
* @param {import('sequelize').WhereOptions} [where]
* @returns {Array<objects.LibraryItem>} old library items
*/
static async getAllOldLibraryItems(where = null) {
let libraryItems = await this.findAll({
where,
include: [
{
model: this.sequelize.models.book,
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
},
{
model: this.sequelize.models.podcast,
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
}
]
})
return libraryItems.map((ti) => this.getOldLibraryItem(ti))
}
/** /**
* Convert an expanded LibraryItem into an old library item * Convert an expanded LibraryItem into an old library item
* *
@@ -199,40 +160,6 @@ class LibraryItem extends Model {
}) })
} }
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 * Updates libraryItem, book, authors and series from old library item
* *
@@ -448,6 +375,47 @@ class LibraryItem extends Model {
}) })
} }
/**
*
* @param {import('sequelize').WhereOptions} where
* @returns {Promise<LibraryItemExpanded[]>}
*/
static async findAllExpandedWhere(where = null) {
return this.findAll({
where,
include: [
{
model: this.sequelize.models.book,
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
},
{
model: this.sequelize.models.podcast,
include: {
model: this.sequelize.models.podcastEpisode
}
}
],
order: [
// 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']
]
})
}
/** /**
* *
* @param {string} libraryItemId * @param {string} libraryItemId
@@ -497,6 +465,57 @@ class LibraryItem extends Model {
return libraryItem return libraryItem
} }
/**
*
* @param {import('sequelize').WhereOptions} where
* @param {import('sequelize').IncludeOptions} [include]
* @returns {Promise<LibraryItemExpanded>}
*/
static async findOneExpanded(where, include = null) {
const libraryItem = await this.findOne({
where,
include
})
if (!libraryItem) {
Logger.error(`[LibraryItem] Library item not found`)
return null
}
if (libraryItem.mediaType === 'podcast') {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['id', 'sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
if (!libraryItem.media) return null
return libraryItem
}
/** /**
* Get old library item by id * Get old library item by id
* @param {string} libraryItemId * @param {string} libraryItemId
@@ -560,7 +579,7 @@ class LibraryItem extends Model {
return { return {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = li.toOldJSONMinified()
if (li.collapsedSeries) { if (li.collapsedSeries) {
oldLibraryItem.collapsedSeries = li.collapsedSeries oldLibraryItem.collapsedSeries = li.collapsedSeries
} }
@@ -766,21 +785,11 @@ class LibraryItem extends Model {
* Get book library items for author, optional use user permissions * Get book library items for author, optional use user permissions
* @param {import('./Author')} author * @param {import('./Author')} author
* @param {import('./User')} user * @param {import('./User')} user
* @returns {Promise<oldLibraryItem[]>} * @returns {Promise<LibraryItemExpanded[]>}
*/ */
static async getForAuthor(author, user = null) { static async getForAuthor(author, user = null) {
const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined) const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined)
return libraryItems.map((li) => this.getOldLibraryItem(li)) return libraryItems
}
/**
* 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))
} }
/** /**
@@ -1176,6 +1185,22 @@ class LibraryItem extends Model {
} }
} }
/**
* 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 * @param {string} ino
+15 -31
View File
@@ -76,42 +76,26 @@ class MediaItemShare extends Model {
} }
/** /**
* Expanded book that includes library settings
* *
* @param {string} mediaItemId * @param {string} mediaItemId
* @param {string} mediaItemType * @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') { if (mediaItemType === 'book') {
const book = await this.sequelize.models.book.findByPk(mediaItemId, { const libraryItem = await libraryItemModel.findOneExpanded(
include: [ { mediaId: mediaItemId },
{ {
model: this.sequelize.models.author, model: this.sequelize.models.library,
through: { attributes: ['settings']
attributes: [] }
} )
},
{ return libraryItem
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
},
{
model: this.sequelize.models.libraryItem,
include: {
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 null return null
} }
+46 -2
View File
@@ -1,5 +1,6 @@
const { DataTypes, Model, Op } = require('sequelize') const { DataTypes, Model, Op } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
class Playlist extends Model { class Playlist extends Model {
constructor(values, options) { constructor(values, options) {
@@ -163,6 +164,49 @@ class Playlist extends Model {
return playlists 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 * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@@ -313,7 +357,7 @@ class Playlist extends Model {
libraryItem.media = pmi.mediaItem libraryItem.media = pmi.mediaItem
return { return {
libraryItemId: libraryItem.id, 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, episodeId: pmi.mediaItemId,
episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id), episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified() libraryItem: libraryItem.toOldJSONMinified()
} }
}) })
+140
View File
@@ -126,6 +126,45 @@ class Podcast extends Model {
} }
} }
/**
* 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
},
{ transaction }
)
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@@ -259,6 +298,10 @@ class Podcast extends Model {
this.autoDownloadSchedule = payload.autoDownloadSchedule this.autoDownloadSchedule = payload.autoDownloadSchedule
hasUpdates = true hasUpdates = true
} }
if (typeof payload.lastEpisodeCheck === 'number' && payload.lastEpisodeCheck !== this.lastEpisodeCheck?.valueOf()) {
this.lastEpisodeCheck = payload.lastEpisodeCheck
hasUpdates = true
}
const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload'] const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload']
numberKeys.forEach((key) => { numberKeys.forEach((key) => {
@@ -276,6 +319,103 @@ class Podcast extends Model {
return hasUpdates 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 * Old model kept metadata in a separate object
*/ */
+66 -10
View File
@@ -87,6 +87,40 @@ class PodcastEpisode extends Model {
} }
} }
/**
*
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode
* @param {string} podcastId
* @param {import('../objects/files/AudioFile')} audioFile
*/
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 this.create(podcastEpisode)
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@@ -135,23 +169,45 @@ class PodcastEpisode extends Model {
PodcastEpisode.belongsTo(podcast) 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() { checkMatchesGuidOrEnclosureUrl(guid, enclosureURL) {
if (!this.audioFile) return null 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) const track = structuredClone(this.audioFile)
track.startOffset = 0 track.startOffset = 0
track.title = this.audioFile.metadata.title track.title = this.audioFile.metadata.title
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
return track return track
} }
get size() {
return this.audioFile?.metadata.size || 0
}
/** /**
* @param {string} libraryItemId * @param {string} libraryItemId
* @returns {oldPodcastEpisode} * @returns {oldPodcastEpisode}
@@ -228,9 +284,9 @@ class PodcastEpisode extends Model {
toOldJSONExpanded(libraryItemId) { toOldJSONExpanded(libraryItemId) {
const json = this.toOldJSON(libraryItemId) const json = this.toOldJSON(libraryItemId)
json.audioTrack = this.track json.audioTrack = this.getAudioTrack(libraryItemId)
json.size = this.size json.size = this.size
json.duration = this.audioFile?.duration || 0 json.duration = this.duration
return json return json
} }
-55
View File
@@ -1,4 +1,3 @@
const uuidv4 = require('uuid').v4
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
@@ -178,45 +177,6 @@ class LibraryItem {
return this.libraryFiles.some((lf) => lf.fileType === 'audio') return this.libraryFiles.some((lf) => lf.fileType === 'audio')
} }
// 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) { update(payload) {
const json = this.toJSON() const json = this.toJSON()
let hasUpdates = false let hasUpdates = false
@@ -249,10 +209,6 @@ class LibraryItem {
this.updatedAt = Date.now() this.updatedAt = Date.now()
} }
getDirectPlayTracklist(episodeId) {
return this.media.getDirectPlayTracklist(episodeId)
}
/** /**
* Save metadata.json file * Save metadata.json file
* TODO: Move to new LibraryItem model * TODO: Move to new LibraryItem model
@@ -313,16 +269,5 @@ class LibraryItem {
this.isSavingMetadata = false 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
}
} }
module.exports = LibraryItem module.exports = LibraryItem
+16 -25
View File
@@ -1,8 +1,6 @@
const date = require('../libs/dateAndTime') const date = require('../libs/dateAndTime')
const uuidv4 = require('uuid').v4 const uuidv4 = require('uuid').v4
const serverVersion = require('../../package.json').version const serverVersion = require('../../package.json').version
const BookMetadata = require('./metadata/BookMetadata')
const PodcastMetadata = require('./metadata/PodcastMetadata')
const DeviceInfo = require('./DeviceInfo') const DeviceInfo = require('./DeviceInfo')
class PlaybackSession { class PlaybackSession {
@@ -60,7 +58,7 @@ class PlaybackSession {
bookId: this.bookId, bookId: this.bookId,
episodeId: this.episodeId, episodeId: this.episodeId,
mediaType: this.mediaType, mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata?.toJSON() || null, mediaMetadata: structuredClone(this.mediaMetadata),
chapters: (this.chapters || []).map((c) => ({ ...c })), chapters: (this.chapters || []).map((c) => ({ ...c })),
displayTitle: this.displayTitle, displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor, displayAuthor: this.displayAuthor,
@@ -82,7 +80,7 @@ class PlaybackSession {
/** /**
* Session data to send to clients * Session data to send to clients
* @param {Object} [libraryItem] - old library item * @param {import('../models/LibraryItem')} [libraryItem]
* @returns * @returns
*/ */
toJSONForClient(libraryItem) { toJSONForClient(libraryItem) {
@@ -94,7 +92,7 @@ class PlaybackSession {
bookId: this.bookId, bookId: this.bookId,
episodeId: this.episodeId, episodeId: this.episodeId,
mediaType: this.mediaType, mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata?.toJSON() || null, mediaMetadata: structuredClone(this.mediaMetadata),
chapters: (this.chapters || []).map((c) => ({ ...c })), chapters: (this.chapters || []).map((c) => ({ ...c })),
displayTitle: this.displayTitle, displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor, displayAuthor: this.displayAuthor,
@@ -112,7 +110,7 @@ class PlaybackSession {
startedAt: this.startedAt, startedAt: this.startedAt,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }), 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.serverVersion = session.serverVersion
this.chapters = session.chapters || [] this.chapters = session.chapters || []
this.mediaMetadata = null this.mediaMetadata = session.mediaMetadata
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.displayTitle = session.displayTitle || '' this.displayTitle = session.displayTitle || ''
this.displayAuthor = session.displayAuthor || '' this.displayAuthor = session.displayAuthor || ''
this.coverPath = session.coverPath 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) { setData(libraryItem, userId, mediaPlayer, deviceInfo, startTime, episodeId = null) {
this.id = uuidv4() this.id = uuidv4()
this.userId = userId this.userId = userId
@@ -213,13 +213,12 @@ class PlaybackSession {
this.bookId = episodeId ? null : libraryItem.media.id this.bookId = episodeId ? null : libraryItem.media.id
this.episodeId = episodeId this.episodeId = episodeId
this.mediaType = libraryItem.mediaType this.mediaType = libraryItem.mediaType
this.mediaMetadata = libraryItem.media.metadata.clone() this.mediaMetadata = libraryItem.media.oldMetadataToJSON()
this.chapters = libraryItem.media.getChapters(episodeId) this.chapters = libraryItem.media.getChapters(episodeId)
this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId) this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId)
this.displayAuthor = libraryItem.media.getPlaybackAuthor() this.displayAuthor = libraryItem.media.getPlaybackAuthor()
this.coverPath = libraryItem.media.coverPath this.coverPath = libraryItem.media.coverPath
this.duration = libraryItem.media.getPlaybackDuration(episodeId)
this.setDuration(libraryItem, episodeId)
this.mediaPlayer = mediaPlayer this.mediaPlayer = mediaPlayer
this.deviceInfo = deviceInfo || new DeviceInfo() this.deviceInfo = deviceInfo || new DeviceInfo()
@@ -235,14 +234,6 @@ class PlaybackSession {
this.updatedAt = Date.now() this.updatedAt = Date.now()
} }
setDuration(libraryItem, episodeId) {
if (episodeId) {
this.duration = libraryItem.media.getEpisodeDuration(episodeId)
} else {
this.duration = libraryItem.media.duration
}
}
addListeningTime(timeListened) { addListeningTime(timeListened) {
if (!timeListened || isNaN(timeListened)) return if (!timeListened || isNaN(timeListened)) return
+35 -19
View File
@@ -6,8 +6,11 @@ const globals = require('../utils/globals')
class PodcastEpisodeDownload { class PodcastEpisodeDownload {
constructor() { constructor() {
this.id = null this.id = null
this.podcastEpisode = null /** @type {import('../utils/podcastUtils').RssPodcastEpisode} */
this.rssPodcastEpisode = null
this.url = null this.url = null
/** @type {import('../models/LibraryItem')} */
this.libraryItem = null this.libraryItem = null
this.libraryId = null this.libraryId = null
@@ -15,7 +18,7 @@ class PodcastEpisodeDownload {
this.isFinished = false this.isFinished = false
this.failed = false this.failed = false
this.appendEpisodeId = false this.appendRandomId = false
this.startedAt = null this.startedAt = null
this.createdAt = null this.createdAt = null
@@ -25,22 +28,22 @@ class PodcastEpisodeDownload {
toJSONForClient() { toJSONForClient() {
return { return {
id: this.id, id: this.id,
episodeDisplayTitle: this.podcastEpisode?.title ?? null, episodeDisplayTitle: this.rssPodcastEpisode?.title ?? null,
url: this.url, url: this.url,
libraryItemId: this.libraryItem?.id || null, libraryItemId: this.libraryItemId,
libraryId: this.libraryId || null, libraryId: this.libraryId || null,
isFinished: this.isFinished, isFinished: this.isFinished,
failed: this.failed, failed: this.failed,
appendEpisodeId: this.appendEpisodeId, appendRandomId: this.appendRandomId,
startedAt: this.startedAt, startedAt: this.startedAt,
createdAt: this.createdAt, createdAt: this.createdAt,
finishedAt: this.finishedAt, finishedAt: this.finishedAt,
podcastTitle: this.libraryItem?.media.metadata.title ?? null, podcastTitle: this.libraryItem?.media.title ?? null,
podcastExplicit: !!this.libraryItem?.media.metadata.explicit, podcastExplicit: !!this.libraryItem?.media.explicit,
season: this.podcastEpisode?.season ?? null, season: this.rssPodcastEpisode?.season ?? null,
episode: this.podcastEpisode?.episode ?? null, episode: this.rssPodcastEpisode?.episode ?? null,
episodeType: this.podcastEpisode?.episodeType ?? 'full', episodeType: this.rssPodcastEpisode?.episodeType ?? 'full',
publishedAt: this.podcastEpisode?.publishedAt ?? null publishedAt: this.rssPodcastEpisode?.publishedAt ?? null
} }
} }
@@ -54,7 +57,7 @@ class PodcastEpisodeDownload {
return 'mp3' return 'mp3'
} }
get enclosureType() { get enclosureType() {
const enclosureType = this.podcastEpisode?.enclosure?.type const enclosureType = this.rssPodcastEpisode.enclosure.type
return typeof enclosureType === 'string' ? enclosureType : null return typeof enclosureType === 'string' ? enclosureType : null
} }
/** /**
@@ -67,10 +70,12 @@ class PodcastEpisodeDownload {
if (this.enclosureType && !this.enclosureType.includes('mpeg')) return false if (this.enclosureType && !this.enclosureType.includes('mpeg')) return false
return this.fileExtension === 'mp3' return this.fileExtension === 'mp3'
} }
get episodeTitle() {
return this.rssPodcastEpisode.title
}
get targetFilename() { get targetFilename() {
const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : '' const appendage = this.appendRandomId ? ` (${uuidv4()})` : ''
const filename = `${this.podcastEpisode.title}${appendage}.${this.fileExtension}` const filename = `${this.rssPodcastEpisode.title}${appendage}.${this.fileExtension}`
return sanitizeFilename(filename) return sanitizeFilename(filename)
} }
get targetPath() { get targetPath() {
@@ -80,14 +85,25 @@ class PodcastEpisodeDownload {
return this.targetFilename return this.targetFilename
} }
get libraryItemId() { 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.id = uuidv4()
this.podcastEpisode = podcastEpisode this.rssPodcastEpisode = rssPodcastEpisode
const url = podcastEpisode.enclosure.url const url = rssPodcastEpisode.enclosure.url
if (decodeURIComponent(url) !== url) { if (decodeURIComponent(url) !== url) {
// Already encoded // Already encoded
this.url = url this.url = url
+12 -17
View File
@@ -18,6 +18,7 @@ class Stream extends EventEmitter {
this.id = sessionId this.id = sessionId
this.user = user this.user = user
/** @type {import('../models/LibraryItem')} */
this.libraryItem = libraryItem this.libraryItem = libraryItem
this.episodeId = episodeId this.episodeId = episodeId
@@ -40,31 +41,25 @@ class Stream extends EventEmitter {
this.furthestSegmentCreated = 0 this.furthestSegmentCreated = 0
} }
get isPodcast() { /**
return this.libraryItem.mediaType === 'podcast' * @returns {import('../models/PodcastEpisode') | null}
} */
get episode() { get episode() {
if (!this.isPodcast) return null if (!this.libraryItem.isPodcast) return null
return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId) return this.libraryItem.media.podcastEpisodes.find((ep) => ep.id === this.episodeId)
}
get libraryItemId() {
return this.libraryItem.id
} }
get mediaTitle() { get mediaTitle() {
if (this.episode) return this.episode.title || '' return this.libraryItem.media.getPlaybackTitle(this.episodeId)
return this.libraryItem.media.metadata.title || ''
} }
get totalDuration() { get totalDuration() {
if (this.episode) return this.episode.duration return this.libraryItem.media.getPlaybackDuration(this.episodeId)
return this.libraryItem.media.duration
} }
get tracks() { get tracks() {
if (this.episode) return this.episode.tracks return this.libraryItem.getTrackList(this.episodeId)
return this.libraryItem.media.tracks
} }
get tracksAudioFileType() { get tracksAudioFileType() {
if (!this.tracks.length) return null if (!this.tracks.length) return null
return this.tracks[0].metadata.format return this.tracks[0].metadata.ext.slice(1)
} }
get tracksMimeType() { get tracksMimeType() {
if (!this.tracks.length) return null if (!this.tracks.length) return null
@@ -116,8 +111,8 @@ class Stream extends EventEmitter {
return { return {
id: this.id, id: this.id,
userId: this.user.id, userId: this.user.id,
libraryItem: this.libraryItem.toJSONExpanded(), libraryItem: this.libraryItem.toOldJSONExpanded(),
episode: this.episode ? this.episode.toJSONExpanded() : null, episode: this.episode ? this.episode.toOldJSONExpanded(this.libraryItem.id) : null,
segmentLength: this.segmentLength, segmentLength: this.segmentLength,
playlistPath: this.playlistPath, playlistPath: this.playlistPath,
clientPlaylistUri: this.clientPlaylistUri, clientPlaylistUri: this.clientPlaylistUri,
-37
View File
@@ -1,4 +1,3 @@
const uuidv4 = require('uuid').v4
const { areEquivalent, copyValue } = require('../../utils/index') const { areEquivalent, copyValue } = require('../../utils/index')
const AudioFile = require('../files/AudioFile') const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack') const AudioTrack = require('../files/AudioTrack')
@@ -127,27 +126,6 @@ class PodcastEpisode {
get enclosureUrl() { get enclosureUrl() {
return this.enclosure?.url || null 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) { update(payload) {
let hasUpdates = false let hasUpdates = false
@@ -167,20 +145,5 @@ class PodcastEpisode {
} }
return hasUpdates 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 module.exports = PodcastEpisode
-22
View File
@@ -150,27 +150,5 @@ class Book {
this.coverPath = coverPath this.coverPath = coverPath
return true return true
} }
// 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 module.exports = Book
-78
View File
@@ -132,18 +132,6 @@ class Podcast {
get numTracks() { get numTracks() {
return this.episodes.length 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) { update(payload) {
var json = this.toJSON() var json = this.toJSON()
@@ -178,68 +166,6 @@ class Podcast {
return true return true
} }
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) { getEpisode(episodeId) {
if (!episodeId) return null if (!episodeId) return null
@@ -248,9 +174,5 @@ class Podcast {
return this.episodes.find((ep) => ep.id == episodeId) return this.episodes.find((ep) => ep.id == episodeId)
} }
getChapters(episodeId) {
return this.getEpisode(episodeId)?.chapters?.map((ch) => ({ ...ch })) || []
}
} }
module.exports = Podcast module.exports = Podcast
-5
View File
@@ -159,11 +159,6 @@ class BookMetadata {
getSeries(seriesId) { getSeries(seriesId) {
return this.series.find((se) => se.id == 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) { update(payload) {
const json = this.toJSON() const json = this.toJSON()
@@ -91,24 +91,6 @@ class PodcastMetadata {
return getTitlePrefixAtEnd(this.title) 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) { update(payload) {
const json = this.toJSON() const json = this.toJSON()
let hasUpdates = false let hasUpdates = false
+1 -30
View File
@@ -361,36 +361,7 @@ class ApiRouter {
} }
// remove item from playlists // remove item from playlists
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds) await Database.playlistModel.removeMediaItemsFromPlaylists(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)
}
}
// Close rss feed - remove from db and emit socket event // Close rss feed - remove from db and emit socket event
await RssFeedManager.closeFeedForEntityId(libraryItemId) await RssFeedManager.closeFeedForEntityId(libraryItemId)
+1 -2
View File
@@ -64,8 +64,7 @@ class LibraryItemScanner {
const { libraryItem: expandedLibraryItem, wasUpdated } = await this.rescanLibraryItemMedia(libraryItem, libraryItemScanData, library.settings, scanLogger) const { libraryItem: expandedLibraryItem, wasUpdated } = await this.rescanLibraryItemMedia(libraryItem, libraryItemScanData, library.settings, scanLogger)
if (libraryItemDataUpdated || wasUpdated) { if (libraryItemDataUpdated || wasUpdated) {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(expandedLibraryItem) SocketAuthority.emitter('item_updated', expandedLibraryItem.toOldJSONExpanded())
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger) await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger)
+22 -25
View File
@@ -167,7 +167,7 @@ class LibraryScanner {
if (this.shouldCancelScan(libraryScan)) return true if (this.shouldCancelScan(libraryScan)) return true
const libraryItemIdsMissing = [] const libraryItemIdsMissing = []
let oldLibraryItemsUpdated = [] let libraryItemsUpdated = []
for (const existingLibraryItem of existingLibraryItems) { for (const existingLibraryItem of existingLibraryItems) {
// First try to find matching library item with exact file path // First try to find matching library item with exact file path
let libraryItemData = libraryItemDataFound.find((lid) => lid.path === existingLibraryItem.path) let libraryItemData = libraryItemDataFound.find((lid) => lid.path === existingLibraryItem.path)
@@ -190,11 +190,11 @@ class LibraryScanner {
libraryItemIdsMissing.push(existingLibraryItem.id) libraryItemIdsMissing.push(existingLibraryItem.id)
// TODO: Temporary while using old model to socket emit // TODO: Temporary while using old model to socket emit
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id) const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id)
if (oldLibraryItem) { if (libraryItem) {
oldLibraryItem.isMissing = true libraryItem.isMissing = true
oldLibraryItem.updatedAt = Date.now() await libraryItem.save()
oldLibraryItemsUpdated.push(oldLibraryItem) libraryItemsUpdated.push(libraryItem)
} }
} }
} }
@@ -206,16 +206,15 @@ class LibraryScanner {
const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan) const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)
if (!forceRescan || wasUpdated) { if (!forceRescan || wasUpdated) {
libraryScan.resultsUpdated++ libraryScan.resultsUpdated++
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) libraryItemsUpdated.push(libraryItem)
oldLibraryItemsUpdated.push(oldLibraryItem)
} else { } else {
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`) libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
} }
} else { } else {
libraryScan.resultsUpdated++ libraryScan.resultsUpdated++
// TODO: Temporary while using old model to socket emit // TODO: Temporary while using old model to socket emit
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id) const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id)
oldLibraryItemsUpdated.push(oldLibraryItem) libraryItemsUpdated.push(libraryItem)
} }
} else { } else {
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`) 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 // 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 // TODO: Should only emit to clients where library item is accessible
SocketAuthority.emitter( SocketAuthority.emitter(
'items_updated', 'items_updated',
oldLibraryItemsUpdated.map((li) => li.toJSONExpanded()) libraryItemsUpdated.map((li) => li.toOldJSONExpanded())
) )
oldLibraryItemsUpdated = [] libraryItemsUpdated = []
} }
if (this.shouldCancelScan(libraryScan)) return true if (this.shouldCancelScan(libraryScan)) return true
} }
// Emit item updates to client // Emit item updates to client
if (oldLibraryItemsUpdated.length) { if (libraryItemsUpdated.length) {
// TODO: Should only emit to clients where library item is accessible // TODO: Should only emit to clients where library item is accessible
SocketAuthority.emitter( SocketAuthority.emitter(
'items_updated', 'items_updated',
oldLibraryItemsUpdated.map((li) => li.toJSONExpanded()) libraryItemsUpdated.map((li) => li.toOldJSONExpanded())
) )
} }
@@ -267,34 +266,33 @@ class LibraryScanner {
// Add new library items // Add new library items
if (libraryItemDataFound.length) { if (libraryItemDataFound.length) {
let newOldLibraryItems = [] let newLibraryItems = []
for (const libraryItemData of libraryItemDataFound) { for (const libraryItemData of libraryItemDataFound) {
const newLibraryItem = await LibraryItemScanner.scanNewLibraryItem(libraryItemData, libraryScan.library.settings, libraryScan) const newLibraryItem = await LibraryItemScanner.scanNewLibraryItem(libraryItemData, libraryScan.library.settings, libraryScan)
if (newLibraryItem) { if (newLibraryItem) {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem) newLibraryItems.push(newLibraryItem)
newOldLibraryItems.push(oldLibraryItem)
libraryScan.resultsAdded++ libraryScan.resultsAdded++
} }
// Emit new items in chunks of 10 to client // 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 // TODO: Should only emit to clients where library item is accessible
SocketAuthority.emitter( SocketAuthority.emitter(
'items_added', 'items_added',
newOldLibraryItems.map((li) => li.toJSONExpanded()) newLibraryItems.map((li) => li.toOldJSONExpanded())
) )
newOldLibraryItems = [] newLibraryItems = []
} }
if (this.shouldCancelScan(libraryScan)) return true if (this.shouldCancelScan(libraryScan)) return true
} }
// Emit new items to client // Emit new items to client
if (newOldLibraryItems.length) { if (newLibraryItems.length) {
// TODO: Should only emit to clients where library item is accessible // TODO: Should only emit to clients where library item is accessible
SocketAuthority.emitter( SocketAuthority.emitter(
'items_added', 'items_added',
newOldLibraryItems.map((li) => li.toJSONExpanded()) newLibraryItems.map((li) => li.toOldJSONExpanded())
) )
} }
} }
@@ -645,8 +643,7 @@ class LibraryScanner {
const isSingleMediaItem = isSingleMediaFile(fileUpdateGroup, itemDir) const isSingleMediaItem = isSingleMediaFile(fileUpdateGroup, itemDir)
const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem) const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem)
if (newLibraryItem) { if (newLibraryItem) {
const oldNewLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem) SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
SocketAuthority.emitter('item_added', oldNewLibraryItem.toJSONExpanded())
} }
itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING
} }
+19 -13
View File
@@ -97,6 +97,11 @@ async function resizeImage(filePath, outputPath, width, height) {
} }
module.exports.resizeImage = resizeImage module.exports.resizeImage = resizeImage
/**
*
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
* @returns
*/
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
const response = await axios({ const response = await axios({
@@ -118,32 +123,33 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
ffmpeg.addOption('-loglevel debug') // Debug logs printed on error ffmpeg.addOption('-loglevel debug') // Debug logs printed on error
ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1') ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1')
const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata /** @type {import('../models/Podcast')} */
const podcastEpisode = podcastEpisodeDownload.podcastEpisode const podcast = podcastEpisodeDownload.libraryItem.media
const podcastEpisode = podcastEpisodeDownload.rssPodcastEpisode
const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0) const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0)
const taggings = { const taggings = {
album: podcastMetadata.title, album: podcast.title,
'album-sort': podcastMetadata.title, 'album-sort': podcast.title,
artist: podcastMetadata.author, artist: podcast.author,
'artist-sort': podcastMetadata.author, 'artist-sort': podcast.author,
comment: podcastEpisode.description, comment: podcastEpisode.description,
subtitle: podcastEpisode.subtitle, subtitle: podcastEpisode.subtitle,
disc: podcastEpisode.season, disc: podcastEpisode.season,
genre: podcastMetadata.genres.length ? podcastMetadata.genres.join(';') : null, genre: podcast.genres.length ? podcast.genres.join(';') : null,
language: podcastMetadata.language, language: podcast.language,
MVNM: podcastMetadata.title, MVNM: podcast.title,
MVIN: podcastEpisode.episode, MVIN: podcastEpisode.episode,
track: podcastEpisode.episode, track: podcastEpisode.episode,
'series-part': podcastEpisode.episode, 'series-part': podcastEpisode.episode,
title: podcastEpisode.title, title: podcastEpisode.title,
'title-sort': podcastEpisode.title, 'title-sort': podcastEpisode.title,
year: podcastEpisode.pubYear, year: podcastEpisodeDownload.pubYear,
date: podcastEpisode.pubDate, date: podcastEpisode.pubDate,
releasedate: podcastEpisode.pubDate, releasedate: podcastEpisode.pubDate,
'itunes-id': podcastMetadata.itunesId, 'itunes-id': podcast.itunesId,
'podcast-type': podcastMetadata.type, 'podcast-type': podcast.podcastType,
'episode-type': podcastMetadata.episodeType 'episode-type': podcastEpisode.episodeType
} }
for (const tag in taggings) { for (const tag in taggings) {
+1 -1
View File
@@ -1200,7 +1200,7 @@ async function migrationPatchNewColumns(queryInterface) {
*/ */
async function handleOldLibraryItems(ctx) { async function handleOldLibraryItems(ctx) {
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems') const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
const libraryItems = await ctx.models.libraryItem.getAllOldLibraryItems() const libraryItems = (await ctx.models.libraryItem.findAllExpandedWhere()).map((li) => ctx.models.libraryItem.getOldLibraryItem(li))
const bulkUpdateItems = [] const bulkUpdateItems = []
const bulkUpdateEpisodes = [] const bulkUpdateEpisodes = []
+44 -1
View File
@@ -4,6 +4,49 @@ const Logger = require('../Logger')
const { xmlToJSON, levenshteinDistance } = require('./index') const { xmlToJSON, levenshteinDistance } = require('./index')
const htmlSanitizer = require('../utils/htmlSanitizer') 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) { function extractFirstArrayItem(json, key) {
if (!json[key]?.length) return null if (!json[key]?.length) return null
return json[key][0] return json[key][0]
@@ -223,7 +266,7 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal
* *
* @param {string} feedUrl * @param {string} feedUrl
* @param {boolean} [excludeEpisodeMetadata=false] * @param {boolean} [excludeEpisodeMetadata=false]
* @returns {Promise} * @returns {Promise<RssPodcast|null>}
*/ */
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`) Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
+14 -12
View File
@@ -18,7 +18,7 @@ module.exports = {
* @param {string} libraryId * @param {string} libraryId
* @param {import('../../models/User')} user * @param {import('../../models/User')} user
* @param {object} options * @param {object} options
* @returns {object} { libraryItems:LibraryItem[], count:number } * @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}
*/ */
async getFilteredLibraryItems(libraryId, user, options) { async getFilteredLibraryItems(libraryId, user, options) {
const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = 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) const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'in-progress', 'progress', true, false, include, limit, 0, true)
return { return {
items: libraryItems.map((li) => { items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
@@ -68,7 +68,7 @@ module.exports = {
return { return {
count, count,
items: libraryItems.map((li) => { items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = li.toOldJSONMinified()
oldLibraryItem.recentEpisode = li.recentEpisode oldLibraryItem.recentEpisode = li.recentEpisode
return oldLibraryItem 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) const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, false, include, limit, 0)
return { return {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() 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) const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, include, limit, 0)
return { return {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
@@ -136,7 +136,7 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library, user, include, limit, 0) const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library, user, include, limit, 0)
return { return {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() 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) const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'finished', 'progress', true, false, include, limit, 0)
return { return {
items: libraryItems.map((li) => { items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
@@ -182,7 +182,7 @@ module.exports = {
return { return {
count, count,
items: libraryItems.map((li) => { items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = li.toOldJSONMinified()
oldLibraryItem.recentEpisode = li.recentEpisode oldLibraryItem.recentEpisode = li.recentEpisode
return oldLibraryItem return oldLibraryItem
}) })
@@ -293,15 +293,17 @@ module.exports = {
}) })
oldSeries.books = s.bookSeries oldSeries.books = s.bookSeries
.map((bs) => { .map((bs) => {
const libraryItem = bs.book.libraryItem?.toJSON() const libraryItem = bs.book.libraryItem
if (!libraryItem) { if (!libraryItem) {
Logger.warn(`Book series book has no libraryItem`, bs, bs.book, 'series=', series) Logger.warn(`Book series book has no libraryItem`, bs, bs.book, 'series=', series)
return null return null
} }
delete bs.book.libraryItem delete bs.book.libraryItem
bs.book.authors = [] // Not needed
bs.book.series = [] // Not needed
libraryItem.media = bs.book libraryItem.media = bs.book
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified() const oldLibraryItem = libraryItem.toOldJSONMinified()
return oldLibraryItem return oldLibraryItem
}) })
.filter((b) => b) .filter((b) => b)
@@ -373,7 +375,7 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, user, include, limit) const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, user, include, limit)
return { return {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = li.toOldJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
@@ -400,7 +402,7 @@ module.exports = {
return { return {
count, count,
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = li.toOldJSONMinified()
oldLibraryItem.recentEpisode = li.recentEpisode oldLibraryItem.recentEpisode = li.recentEpisode
return oldLibraryItem return oldLibraryItem
}) })
+68 -28
View File
@@ -349,7 +349,7 @@ module.exports = {
* @param {number} limit * @param {number} limit
* @param {number} offset * @param {number} offset
* @param {boolean} isHomePage for home page shelves * @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) { async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset, isHomePage = false) {
// TODO: Handle collapse sub-series // TODO: Handle collapse sub-series
@@ -583,8 +583,8 @@ module.exports = {
}) })
const libraryItems = books.map((bookExpanded) => { const libraryItems = books.map((bookExpanded) => {
const libraryItem = bookExpanded.libraryItem.toJSON() const libraryItem = bookExpanded.libraryItem
const book = bookExpanded.toJSON() const book = bookExpanded
if (filterGroup === 'series' && book.series?.length) { if (filterGroup === 'series' && book.series?.length) {
// For showing sequence on book cover when filtering for series // For showing sequence on book cover when filtering for series
@@ -596,27 +596,37 @@ module.exports = {
} }
delete book.libraryItem 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 // For showing details of collapsed series
if (collapseseries && book.bookSeries?.length) { if (collapseseries && book.series?.length) {
const collapsedSeries = book.bookSeries.find((bs) => collapseSeriesBookSeries.some((cbs) => cbs.id === bs.id)) const collapsedSeries = book.series.find((bs) => collapseSeriesBookSeries.some((cbs) => cbs.id === bs.bookSeries.id))
if (collapsedSeries) { if (collapsedSeries) {
const collapseSeriesObj = collapseSeriesBookSeries.find((csbs) => csbs.id === collapsedSeries.id) const collapseSeriesObj = collapseSeriesBookSeries.find((csbs) => csbs.id === collapsedSeries.bookSeries.id)
libraryItem.collapsedSeries = { libraryItem.collapsedSeries = {
id: collapsedSeries.series.id, id: collapsedSeries.id,
name: collapsedSeries.series.name, name: collapsedSeries.name,
nameIgnorePrefix: collapsedSeries.series.nameIgnorePrefix, nameIgnorePrefix: collapsedSeries.nameIgnorePrefix,
sequence: collapsedSeries.sequence, sequence: collapsedSeries.bookSeries.sequence,
numBooks: collapseSeriesObj?.numBooks || 0, numBooks: collapseSeriesObj?.numBooks || 0,
libraryItemIds: collapseSeriesObj?.libraryItemIds || [] libraryItemIds: collapseSeriesObj?.libraryItemIds || []
} }
} }
} }
if (bookExpanded.libraryItem.feeds?.length) { if (libraryItem.feeds?.length) {
libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0] libraryItem.rssFeed = libraryItem.feeds[0]
} }
if (includeMediaItemShare) { if (includeMediaItemShare) {
@@ -646,7 +656,7 @@ module.exports = {
* @param {string[]} include * @param {string[]} include
* @param {number} limit * @param {number} limit
* @param {number} offset * @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) { async getContinueSeriesLibraryItems(library, user, include, limit, offset) {
const libraryId = library.id const libraryId = library.id
@@ -758,16 +768,19 @@ module.exports = {
} }
} }
const libraryItem = s.bookSeries[bookIndex].book.libraryItem.toJSON() const libraryItem = s.bookSeries[bookIndex].book.libraryItem
const book = s.bookSeries[bookIndex].book.toJSON() const book = s.bookSeries[bookIndex].book
delete book.libraryItem delete book.libraryItem
book.series = []
libraryItem.series = { libraryItem.series = {
id: s.id, id: s.id,
name: s.name, name: s.name,
sequence: s.bookSeries[bookIndex].sequence sequence: s.bookSeries[bookIndex].sequence
} }
if (s.bookSeries[bookIndex].book.libraryItem.feeds?.length) { if (libraryItem.feeds?.length) {
libraryItem.rssFeed = s.bookSeries[bookIndex].book.libraryItem.feeds[0] libraryItem.rssFeed = libraryItem.feeds[0]
} }
libraryItem.media = book libraryItem.media = book
return libraryItem return libraryItem
@@ -788,7 +801,7 @@ module.exports = {
* @param {import('../../models/User')} user * @param {import('../../models/User')} user
* @param {string[]} include * @param {string[]} include
* @param {number} limit * @param {number} limit
* @returns {object} {libraryItems:LibraryItem, count:number} * @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}
*/ */
async getDiscoverLibraryItems(libraryId, user, include, limit) { async getDiscoverLibraryItems(libraryId, user, include, limit) {
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user) const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
@@ -895,13 +908,26 @@ module.exports = {
// Step 3: Map books to library items // Step 3: Map books to library items
const libraryItems = books.map((bookExpanded) => { const libraryItems = books.map((bookExpanded) => {
const libraryItem = bookExpanded.libraryItem.toJSON() const libraryItem = bookExpanded.libraryItem
const book = bookExpanded.toJSON() const book = bookExpanded
delete 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 libraryItem.media = book
if (bookExpanded.libraryItem.feeds?.length) { if (libraryItem.feeds?.length) {
libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0] libraryItem.rssFeed = libraryItem.feeds[0]
} }
return libraryItem return libraryItem
@@ -961,11 +987,11 @@ module.exports = {
* Get library items for series * Get library items for series
* @param {import('../../models/Series')} series * @param {import('../../models/Series')} series
* @param {import('../../models/User')} [user] * @param {import('../../models/User')} [user]
* @returns {Promise<import('../../objects/LibraryItem')[]>} * @returns {Promise<import('../../models/LibraryItem')[]>}
*/ */
async getLibraryItemsForSeries(series, user) { async getLibraryItemsForSeries(series, user) {
const { libraryItems } = await this.getFilteredLibraryItems(series.libraryId, user, 'series', series.id, null, null, false, [], null, null) 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) { for (const book of books) {
const libraryItem = book.libraryItem const libraryItem = book.libraryItem
delete 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 libraryItem.media = book
itemMatches.push({ itemMatches.push({
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded() libraryItem: libraryItem.toOldJSONExpanded()
}) })
} }
@@ -1132,7 +1170,9 @@ module.exports = {
const books = series.bookSeries.map((bs) => { const books = series.bookSeries.map((bs) => {
const libraryItem = bs.book.libraryItem const libraryItem = bs.book.libraryItem
libraryItem.media = bs.book libraryItem.media = bs.book
return Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSON() libraryItem.media.authors = []
libraryItem.media.series = []
return libraryItem.toOldJSON()
}) })
seriesMatches.push({ seriesMatches.push({
series: series.toOldJSON(), series: series.toOldJSON(),
@@ -107,7 +107,7 @@ module.exports = {
* @param {string[]} include * @param {string[]} include
* @param {number} limit * @param {number} limit
* @param {number} offset * @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) { async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) {
const includeRSSFeed = include.includes('rssfeed') const includeRSSFeed = include.includes('rssfeed')
@@ -175,16 +175,19 @@ module.exports = {
}) })
const libraryItems = podcasts.map((podcastExpanded) => { const libraryItems = podcasts.map((podcastExpanded) => {
const libraryItem = podcastExpanded.libraryItem.toJSON() const libraryItem = podcastExpanded.libraryItem
const podcast = podcastExpanded.toJSON() const podcast = podcastExpanded
delete podcast.libraryItem delete podcast.libraryItem
if (podcastExpanded.libraryItem.feeds?.length) { if (libraryItem.feeds?.length) {
libraryItem.rssFeed = podcastExpanded.libraryItem.feeds[0] libraryItem.rssFeed = libraryItem.feeds[0]
} }
if (podcast.numEpisodesIncomplete) { if (podcast.dataValues.numEpisodesIncomplete) {
libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete libraryItem.numEpisodesIncomplete = podcast.dataValues.numEpisodesIncomplete
}
if (podcast.dataValues.numEpisodes) {
podcast.numEpisodes = podcast.dataValues.numEpisodes
} }
libraryItem.media = podcast libraryItem.media = podcast
@@ -209,7 +212,7 @@ module.exports = {
* @param {number} limit * @param {number} limit
* @param {number} offset * @param {number} offset
* @param {boolean} isHomePage for home page shelves * @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) { async getFilteredPodcastEpisodes(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, limit, offset, isHomePage = false) {
if (sortBy === 'progress' && filterGroup !== 'progress') { if (sortBy === 'progress' && filterGroup !== 'progress') {
@@ -289,10 +292,11 @@ module.exports = {
}) })
const libraryItems = podcastEpisodes.map((ep) => { const libraryItems = podcastEpisodes.map((ep) => {
const libraryItem = ep.podcast.libraryItem.toJSON() const libraryItem = ep.podcast.libraryItem
const podcast = ep.podcast.toJSON() const podcast = ep.podcast
delete podcast.libraryItem delete podcast.libraryItem
libraryItem.media = podcast libraryItem.media = podcast
libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON() libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON()
return libraryItem return libraryItem
}) })
@@ -362,8 +366,9 @@ module.exports = {
const libraryItem = podcast.libraryItem const libraryItem = podcast.libraryItem
delete podcast.libraryItem delete podcast.libraryItem
libraryItem.media = podcast libraryItem.media = podcast
libraryItem.media.podcastEpisodes = []
itemMatches.push({ itemMatches.push({
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded() libraryItem: libraryItem.toOldJSONExpanded()
}) })
} }
+8 -2
View File
@@ -162,6 +162,12 @@ module.exports = {
include: [ include: [
{ {
model: Database.libraryItemModel model: Database.libraryItemModel
},
{
model: Database.authorModel
},
{
model: Database.seriesModel
} }
] ]
}, },
@@ -195,10 +201,10 @@ module.exports = {
}) })
}) })
oldSeries.books = s.bookSeries.map((bs) => { oldSeries.books = s.bookSeries.map((bs) => {
const libraryItem = bs.book.libraryItem.toJSON() const libraryItem = bs.book.libraryItem
delete bs.book.libraryItem delete bs.book.libraryItem
libraryItem.media = bs.book libraryItem.media = bs.book
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified() const oldLibraryItem = libraryItem.toOldJSONMinified()
return oldLibraryItem return oldLibraryItem
}) })
allOldSeries.push(oldSeries) allOldSeries.push(oldSeries)