diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 9a51288a..879d50a3 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -303,13 +303,13 @@ export default { this.$axios .patch(`/api/me/progress/batch/update`, updateProgressPayloads) .then(() => { - this.$toast.success('Batch update success!') + this.$toast.success(this.$strings.ToastBatchUpdateSuccess) this.$store.commit('setProcessingBatch', false) this.$store.commit('globals/resetSelectedMediaItems', []) this.$eventBus.$emit('bookshelf_clear_selection') }) .catch((error) => { - this.$toast.error('Batch update failed') + this.$toast.error(this.$strings.ToastBatchUpdateFailed) console.error('Failed to batch update read/not read', error) this.$store.commit('setProcessingBatch', false) }) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index d5109513..b323c820 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -680,7 +680,6 @@ export default { .$patch(apiEndpoint, updatePayload) .then(() => { this.processing = false - toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess) }) .catch((error) => { console.error('Failed', error) diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index b6c135b9..0f75644b 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -110,7 +110,10 @@ export default { itemEpisodeMap() { const map = {} this.itemEpisodes.forEach((item) => { - if (item.enclosure) map[item.enclosure.url.split('?')[0]] = true + if (item.enclosure) { + const cleanUrl = this.getCleanEpisodeUrl(item.enclosure.url) + map[cleanUrl] = true + } }) return map }, @@ -129,6 +132,29 @@ export default { } }, methods: { + /** + * RSS feed episode url is used for matching with existing downloaded episodes. + * Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests. + * These need to be removed in order to detect the same episode each time the feed is pulled. + * + * An RSS feed may include an `id` in the query string. In these cases we want to leave the `id`. + * @see https://github.com/advplyr/audiobookshelf/issues/1896 + * + * @param {string} url - rss feed episode url + * @returns {string} rss feed episode url without dynamic query strings + */ + getCleanEpisodeUrl(url) { + let queryString = url.split('?')[1] + if (!queryString) return url + + const searchParams = new URLSearchParams(queryString) + for (const p of Array.from(searchParams.keys())) { + if (p !== 'id') searchParams.delete(p) + } + + if (!searchParams.toString()) return url + return `${url}?${searchParams.toString()}` + }, inputUpdate() { clearTimeout(this.searchTimeout) this.searchTimeout = setTimeout(() => { @@ -198,7 +224,7 @@ export default { .map((_ep) => { return { ..._ep, - cleanUrl: _ep.enclosure.url.split('?')[0] + cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url) } }) this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1)) diff --git a/client/components/tables/collection/BookTableRow.vue b/client/components/tables/collection/BookTableRow.vue index f8cce0b4..834088d9 100644 --- a/client/components/tables/collection/BookTableRow.vue +++ b/client/components/tables/collection/BookTableRow.vue @@ -188,7 +188,6 @@ export default { .$patch(`/api/me/progress/${this.book.id}`, updatePayload) .then(() => { this.isProcessingReadUpdate = false - this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess) }) .catch((error) => { console.error('Failed', error) diff --git a/client/components/tables/playlist/ItemTableRow.vue b/client/components/tables/playlist/ItemTableRow.vue index bfb43825..ff986a33 100644 --- a/client/components/tables/playlist/ItemTableRow.vue +++ b/client/components/tables/playlist/ItemTableRow.vue @@ -198,7 +198,6 @@ export default { .$patch(routepath, updatePayload) .then(() => { this.isProcessingReadUpdate = false - this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess) }) .catch((error) => { console.error('Failed', error) diff --git a/client/components/tables/podcast/EpisodeTableRow.vue b/client/components/tables/podcast/EpisodeTableRow.vue index b05d1c86..4300b8e1 100644 --- a/client/components/tables/podcast/EpisodeTableRow.vue +++ b/client/components/tables/podcast/EpisodeTableRow.vue @@ -183,7 +183,6 @@ export default { .$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload) .then(() => { this.isProcessingReadUpdate = false - this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess) }) .catch((error) => { console.error('Failed', error) diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index d2160194..ac4e3d8a 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -533,7 +533,6 @@ export default { .$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload) .then(() => { this.isProcessingReadUpdate = false - this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess) }) .catch((error) => { console.error('Failed', error) diff --git a/client/pages/library/_library/series/_id.vue b/client/pages/library/_library/series/_id.vue index c6ad6ad2..865648f8 100644 --- a/client/pages/library/_library/series/_id.vue +++ b/client/pages/library/_library/series/_id.vue @@ -19,7 +19,7 @@ export default { return redirect(`/library/${libraryId}`) } - const series = await app.$axios.$get(`/api/series/${params.id}?include=progress,rssfeed`).catch((error) => { + const series = await app.$axios.$get(`/api/libraries/${library.id}/series/${params.id}?include=progress,rssfeed`).catch((error) => { console.error('Failed', error) return false }) diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js index 23bdc9d7..660ca2c1 100644 --- a/client/players/PlayerHandler.js +++ b/client/players/PlayerHandler.js @@ -320,7 +320,7 @@ export default class PlayerHandler { } this.listeningTimeSinceSync = 0 this.lastSyncTime = 0 - return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 4000 }).catch((error) => { + return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000 }).catch((error) => { console.error('Failed to close session', error) }) } @@ -340,12 +340,13 @@ export default class PlayerHandler { } this.listeningTimeSinceSync = 0 - this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 6000 }).then(() => { + this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000 }).then(() => { this.failedProgressSyncs = 0 }).catch((error) => { console.error('Failed to update session progress', error) + // After 4 failed sync attempts show an alert toast this.failedProgressSyncs++ - if (this.failedProgressSyncs >= 2) { + if (this.failedProgressSyncs >= 4) { this.ctx.showFailedProgressSyncs() this.failedProgressSyncs = 0 } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index cff0182c..8bc38db7 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -391,7 +391,13 @@ class LibraryController { res.sendStatus(200) } - // api/libraries/:id/series + /** + * api/libraries/:id/series + * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open + * + * @param {*} req + * @param {*} res + */ async getAllSeriesForLibrary(req, res) { const libraryItems = req.libraryItems @@ -452,6 +458,42 @@ class LibraryController { res.json(payload) } + /** + * api/libraries/:id/series/:seriesId + * + * Optional includes (e.g. `?include=rssfeed,progress`) + * rssfeed: adds `rssFeed` to series object if a feed is open + * progress: adds `progress` to series object with { libraryItemIds:Array, libraryItemIdsFinished:Array, isFinished:boolean } + * + * @param {*} req + * @param {*} res - Series + */ + async getSeriesForLibrary(req, res) { + const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v) + + const series = Database.series.find(se => se.id === req.params.seriesId) + if (!series) return res.sendStatus(404) + + const libraryItemsInSeries = req.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id)) + + const seriesJson = series.toJSON() + if (include.includes('progress')) { + const libraryItemsFinished = libraryItemsInSeries.filter(li => !!req.user.getMediaProgress(li.id)?.isFinished) + seriesJson.progress = { + libraryItemIds: libraryItemsInSeries.map(li => li.id), + libraryItemIdsFinished: libraryItemsFinished.map(li => li.id), + isFinished: libraryItemsFinished.length >= libraryItemsInSeries.length + } + } + + if (include.includes('rssfeed')) { + const feedObj = this.rssFeedManager.findFeedForEntityId(seriesJson.id) + seriesJson.rssFeed = feedObj?.toJSONMinified() || null + } + + res.json(seriesJson) + } + // api/libraries/:id/collections async getCollectionsForLibrary(req, res) { const libraryItems = req.libraryItems @@ -859,7 +901,7 @@ class LibraryController { middleware(req, res, next) { if (!req.user.checkCanAccessLibrary(req.params.id)) { Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`) - return res.sendStatus(404) + return res.sendStatus(403) } const library = Database.libraries.find(lib => lib.id === req.params.id) diff --git a/server/controllers/SeriesController.js b/server/controllers/SeriesController.js index 9b05d406..41f44b4b 100644 --- a/server/controllers/SeriesController.js +++ b/server/controllers/SeriesController.js @@ -5,6 +5,16 @@ const Database = require('../Database') class SeriesController { constructor() { } + /** + * @deprecated + * /api/series/:id + * + * TODO: Update mobile app to use /api/libraries/:id/series/:seriesId API route instead + * Series are not library specific so we need to know what the library id is + * + * @param {*} req + * @param {*} res + */ async findOne(req, res) { const include = (req.query.include || '').split(',').map(v => v.trim()).filter(v => !!v) @@ -29,7 +39,7 @@ class SeriesController { seriesJson.rssFeed = feedObj?.toJSONMinified() || null } - return res.json(seriesJson) + res.json(seriesJson) } async search(req, res) { @@ -56,9 +66,13 @@ class SeriesController { const series = Database.series.find(se => se.id === req.params.id) if (!series) return res.sendStatus(404) - const libraryItemsInSeries = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id)) - if (libraryItemsInSeries.some(li => !req.user.checkCanAccessLibrary(li.libraryId))) { - Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to the library`, req.user) + /** + * Filter out any library items not accessible to user + */ + const libraryItems = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id)) + const libraryItemsAccessible = libraryItems.filter(req.user.checkCanAccessLibraryItem) + if (libraryItems.length && !libraryItemsAccessible.length) { + Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to any of the books`, req.user) return res.sendStatus(403) } @@ -71,7 +85,7 @@ class SeriesController { } req.series = series - req.libraryItemsInSeries = libraryItemsInSeries + req.libraryItemsInSeries = libraryItemsAccessible next() } } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 1df7ff02..58237ce0 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -78,6 +78,7 @@ class ApiRouter { this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this)) this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this)) this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this)) + this.router.get('/libraries/:id/series/:seriesId', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this)) this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this)) this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this)) this.router.get('/libraries/:id/albums', LibraryController.middleware.bind(this), LibraryController.getAlbumsForLibrary.bind(this))