diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index dc7175bc..171b72cd 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -820,7 +820,6 @@ export default { return null }) if (!libraryItem) return - console.log('Got library itemn', libraryItem) this.store.commit('showEReader', libraryItem) }, selectBtnClick(evt) { diff --git a/client/strings/de.json b/client/strings/de.json index ac7e9d0d..96b2863d 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -308,6 +308,7 @@ "LabelPublishYear": "Jahr", "LabelRecentlyAdded": "Kürzlich hinzugefügt", "LabelRecentSeries": "Aktuelle Serien", + "LabelRecommended": "Recommended", "LabelRegion": "Region", "LabelReleaseDate": "Veröffentlichungsdatum", "LabelRemoveCover": "Lösche Titelbild", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 66ac40bc..ecbd7f32 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -308,6 +308,7 @@ "LabelPublishYear": "Publish Year", "LabelRecentlyAdded": "Recently Added", "LabelRecentSeries": "Recent Series", + "LabelRecommended": "Recommended", "LabelRegion": "Region", "LabelReleaseDate": "Release Date", "LabelRemoveCover": "Remove cover", diff --git a/client/strings/es.json b/client/strings/es.json index 66ac40bc..ecbd7f32 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -308,6 +308,7 @@ "LabelPublishYear": "Publish Year", "LabelRecentlyAdded": "Recently Added", "LabelRecentSeries": "Recent Series", + "LabelRecommended": "Recommended", "LabelRegion": "Region", "LabelReleaseDate": "Release Date", "LabelRemoveCover": "Remove cover", diff --git a/client/strings/fr.json b/client/strings/fr.json index f4f8598d..a3b5716c 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -308,6 +308,7 @@ "LabelPublishYear": "Année d'Edition", "LabelRecentlyAdded": "Derniers Ajouts", "LabelRecentSeries": "Séries Récentes", + "LabelRecommended": "Recommended", "LabelRegion": "Région", "LabelReleaseDate": "Date de Parution", "LabelRemoveCover": "Supprimer la Couverture", diff --git a/client/strings/hr.json b/client/strings/hr.json index 8954f4b1..697e0d6d 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -308,6 +308,7 @@ "LabelPublishYear": "Godina izdavanja", "LabelRecentlyAdded": "Nedavno dodano", "LabelRecentSeries": "Nedavne serije", + "LabelRecommended": "Recommended", "LabelRegion": "Regija", "LabelReleaseDate": "Datum izlaska", "LabelRemoveCover": "Remove cover", diff --git a/client/strings/it.json b/client/strings/it.json index c1501337..4401cc45 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -308,6 +308,7 @@ "LabelPublishYear": "Anno Pubblicazione", "LabelRecentlyAdded": "Aggiunti Recentemente", "LabelRecentSeries": "Serie Recenti", + "LabelRecommended": "Recommended", "LabelRegion": "Regione", "LabelReleaseDate": "Data Release", "LabelRemoveCover": "Remove cover", diff --git a/client/strings/pl.json b/client/strings/pl.json index be34c792..2fb7b14a 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -308,6 +308,7 @@ "LabelPublishYear": "Rok publikacji", "LabelRecentlyAdded": "Niedawno dodany", "LabelRecentSeries": "Ostatnie serie", + "LabelRecommended": "Recommended", "LabelRegion": "Region", "LabelReleaseDate": "Data wydania", "LabelRemoveCover": "Remove cover", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 51a11efc..cc6581bc 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -308,6 +308,7 @@ "LabelPublishYear": "发布年份", "LabelRecentlyAdded": "最近添加", "LabelRecentSeries": "最近添加系列", + "LabelRecommended": "推荐内容", "LabelRegion": "区域", "LabelReleaseDate": "发布日期", "LabelRemoveCover": "移除封面", diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index ccfad479..7e4cfc09 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -192,7 +192,8 @@ class MeController { } const updatedLocalMediaProgress = [] var numServerProgressUpdates = 0 - var localMediaProgress = req.body.localMediaProgress || [] + const updatedServerMediaProgress = [] + const localMediaProgress = req.body.localMediaProgress || [] localMediaProgress.forEach(localProgress => { if (!localProgress.libraryItemId) { @@ -205,18 +206,22 @@ class MeController { return } - var mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId) + let mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId) if (!mediaProgress) { // New media progress from mobile Logger.debug(`[MeController] syncLocalMediaProgress local progress is new - creating ${localProgress.id}`) req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId) + mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId) + updatedServerMediaProgress.push(mediaProgress) numServerProgressUpdates++ } else if (mediaProgress.lastUpdate < localProgress.lastUpdate) { Logger.debug(`[MeController] syncLocalMediaProgress local progress is more recent - updating ${mediaProgress.id}`) req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId) + mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId) + updatedServerMediaProgress.push(mediaProgress) numServerProgressUpdates++ } else if (mediaProgress.lastUpdate > localProgress.lastUpdate) { - var updateTimeDifference = mediaProgress.lastUpdate - localProgress.lastUpdate + const updateTimeDifference = mediaProgress.lastUpdate - localProgress.lastUpdate Logger.debug(`[MeController] syncLocalMediaProgress server progress is more recent by ${updateTimeDifference}ms - ${mediaProgress.id}`) for (const key in localProgress) { @@ -240,7 +245,8 @@ class MeController { res.json({ numServerProgressUpdates, - localProgressUpdates: updatedLocalMediaProgress + localProgressUpdates: updatedLocalMediaProgress, // Array of LocalMediaProgress that were updated from server (server more recent) + serverProgressUpdates: updatedServerMediaProgress // Array of MediaProgress that made updates to server (local more recent) }) } diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 5b103d2f..1d842559 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -81,9 +81,17 @@ class RssFeedManager { } // Check if feed needs to be updated - if (feed.entityType === 'item') { + if (feed.entityType === 'libraryItem') { const libraryItem = this.db.getLibraryItem(feed.entityId) - if (libraryItem && (!feed.entityUpdatedAt || libraryItem.updatedAt > feed.entityUpdatedAt)) { + + let mostRecentlyUpdatedAt = libraryItem.updatedAt + if (libraryItem.isPodcast) { + libraryItem.media.episodes.forEach((episode) => { + if (episode.updatedAt > mostRecentlyUpdatedAt) mostRecentlyUpdatedAt = episode.updatedAt + }) + } + + if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) { Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`) feed.updateFromItem(libraryItem) await this.db.updateEntity('feed', feed) diff --git a/server/objects/Feed.js b/server/objects/Feed.js index 307a842c..cc5e804f 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -110,13 +110,15 @@ class Feed { this.episodes = [] if (isPodcast) { // PODCAST EPISODES media.episodes.forEach((episode) => { - var feedEpisode = new FeedEpisode() + if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt + + const feedEpisode = new FeedEpisode() feedEpisode.setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, this.meta) this.episodes.push(feedEpisode) }) } else { // AUDIOBOOK EPISODES media.tracks.forEach((audioTrack) => { - var feedEpisode = new FeedEpisode() + const feedEpisode = new FeedEpisode() feedEpisode.setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, this.meta) this.episodes.push(feedEpisode) }) @@ -144,13 +146,15 @@ class Feed { this.episodes = [] if (isPodcast) { // PODCAST EPISODES media.episodes.forEach((episode) => { - var feedEpisode = new FeedEpisode() + if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt + + const feedEpisode = new FeedEpisode() feedEpisode.setFromPodcastEpisode(libraryItem, this.serverAddress, this.slug, episode, this.meta) this.episodes.push(feedEpisode) }) } else { // AUDIOBOOK EPISODES media.tracks.forEach((audioTrack) => { - var feedEpisode = new FeedEpisode() + const feedEpisode = new FeedEpisode() feedEpisode.setFromAudiobookTrack(libraryItem, this.serverAddress, this.slug, audioTrack, this.meta) this.episodes.push(feedEpisode) }) diff --git a/server/objects/user/User.js b/server/objects/user/User.js index b019e19b..745cade2 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -314,18 +314,18 @@ class User { } createUpdateMediaProgress(libraryItem, updatePayload, episodeId = null) { - var itemProgress = this.mediaProgress.find(li => { + const itemProgress = this.mediaProgress.find(li => { if (episodeId && li.episodeId !== episodeId) return false return li.libraryItemId === libraryItem.id }) if (!itemProgress) { - var newItemProgress = new MediaProgress() + const newItemProgress = new MediaProgress() newItemProgress.setData(libraryItem.id, updatePayload, episodeId) this.mediaProgress.push(newItemProgress) return true } - var wasUpdated = itemProgress.update(updatePayload) + const wasUpdated = itemProgress.update(updatePayload) if (updatePayload.lastUpdate) itemProgress.lastUpdate = updatePayload.lastUpdate // For local to keep update times in sync return wasUpdated diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js index 41d140f8..93d23466 100644 --- a/server/utils/libraryHelpers.js +++ b/server/utils/libraryHelpers.js @@ -339,6 +339,14 @@ module.exports = { entities: [], category: 'continueSeries' }, + { + id: 'episodes-recently-added', + label: 'Newest Episodes', + labelStringKey: 'LabelNewestEpisodes', + type: 'episode', + entities: [], + category: 'newestEpisodes' + }, { id: 'recently-added', label: 'Recently Added', @@ -347,14 +355,6 @@ module.exports = { entities: [], category: 'newestItems' }, - { - id: 'listen-again', - label: 'Listen Again', - labelStringKey: 'LabelListenAgain', - type: isPodcastLibrary ? 'episode' : mediaType, - entities: [], - category: 'recentlyFinished' - }, { id: 'recent-series', label: 'Recent Series', @@ -363,6 +363,22 @@ module.exports = { entities: [], category: 'newestSeries' }, + { + id: 'recommended', + label: 'Recommended', + labelStringKey: 'LabelRecommended', + type: mediaType, + entities: [], + category: 'recommended' + }, + { + id: 'listen-again', + label: 'Listen Again', + labelStringKey: 'LabelListenAgain', + type: isPodcastLibrary ? 'episode' : mediaType, + entities: [], + category: 'recentlyFinished' + }, { id: 'newest-authors', label: 'Newest Authors', @@ -370,22 +386,13 @@ module.exports = { type: 'authors', entities: [], category: 'newestAuthors' - }, - { - id: 'episodes-recently-added', - label: 'Newest Episodes', - labelStringKey: 'LabelNewestEpisodes', - type: 'episode', - entities: [], - category: 'newestEpisodes' } ] - const categories = ['recentlyListened', 'continueSeries', 'newestEpisodes', 'newestItems', 'newestSeries', 'recentlyFinished', 'newestAuthors'] const categoryMap = {} - categories.forEach((cat) => { - categoryMap[cat] = { - category: cat, + shelves.forEach((shelf) => { + categoryMap[shelf.category] = { + category: shelf.category, biggest: 0, smallest: 0, items: [] @@ -395,6 +402,12 @@ module.exports = { const seriesMap = {} const authorMap = {} + // For use with recommended + const topGenresListened = {} + const topAuthorsListened = {} + const topTagsListened = {} + const notStartedBooks = [] + for (const libraryItem of libraryItems) { if (libraryItem.addedAt > categoryMap.newestItems.smallest) { @@ -494,10 +507,28 @@ module.exports = { } else if (libraryItem.isBook) { // Book categories + const mediaProgress = allItemProgress.length ? allItemProgress[0] : null + + // Used for recommended. Tally up most listened to authors/genres/tags + if (mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)) { + libraryItem.media.metadata.authors.forEach((author) => { + topAuthorsListened[author.id] = (topAuthorsListened[author.id] || 0) + 1 + }) + libraryItem.media.metadata.genres.forEach((genre) => { + topGenresListened[genre] = (topGenresListened[genre] || 0) + 1 + }) + libraryItem.media.tags.forEach((tag) => { + topTagsListened[tag] = (topTagsListened[tag] || 0) + 1 + }) + } else { + // Insert in random position to add randomization to equal weighted items + notStartedBooks.splice(Math.floor(Math.random() * (notStartedBooks.length + 1)), 0, libraryItem) + } + // Newest series if (libraryItem.media.metadata.series.length) { for (const librarySeries of libraryItem.media.metadata.series) { - const mediaProgress = allItemProgress.length ? allItemProgress[0] : null + const bookInProgress = mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished) const bookActive = mediaProgress && mediaProgress.inProgress && !mediaProgress.isFinished const libraryItemJson = libraryItem.toJSONMinified() @@ -602,7 +633,6 @@ module.exports = { } // Book listening and finished - var mediaProgress = allItemProgress.length ? allItemProgress[0] : null if (mediaProgress) { // Handle most recently finished if (mediaProgress.isFinished) { @@ -612,7 +642,7 @@ module.exports = { finishedAt: mediaProgress.finishedAt } - var indexToPut = categoryMap.recentlyFinished.items.findIndex(i => mediaProgress.finishedAt > i.finishedAt) + const indexToPut = categoryMap.recentlyFinished.items.findIndex(i => mediaProgress.finishedAt > i.finishedAt) if (indexToPut >= 0) { categoryMap.recentlyFinished.items.splice(indexToPut, 0, libraryItemObj) } else { @@ -632,7 +662,7 @@ module.exports = { progressLastUpdate: mediaProgress.lastUpdate } - var indexToPut = categoryMap.recentlyListened.items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate) + const indexToPut = categoryMap.recentlyListened.items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate) if (indexToPut >= 0) { categoryMap.recentlyListened.items.splice(indexToPut, 0, libraryItemObj) } else { // Should only happen when array is < max @@ -652,9 +682,9 @@ module.exports = { // For Continue Series - Find next book in series for series that are in progress for (const seriesId in seriesMap) { - if (seriesMap[seriesId].inProgress && !seriesMap[seriesId].hideFromContinueListening) { - seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence) + seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence) + if (seriesMap[seriesId].inProgress && !seriesMap[seriesId].hideFromContinueListening) { // take the first book unread with the smallest series sequence // unless the user is already listening to a book from this series const hasActiveBook = seriesMap[seriesId].hasActiveBook @@ -683,6 +713,76 @@ module.exports = { } } + // For recommended + if (!isPodcastLibrary && notStartedBooks.length) { + const genresCount = Object.values(topGenresListened).reduce((a, b) => a + b, 0) + const authorsCount = Object.values(topAuthorsListened).reduce((a, b) => a + b, 0) + const tagsCount = Object.values(topTagsListened).reduce((a, b) => a + b, 0) + + for (const libraryItem of notStartedBooks) { + // dont include books in an unfinished series and books that are not first in an unstarted series + let shouldContinue = !libraryItem.media.metadata.series.length + libraryItem.media.metadata.series.forEach((se) => { + if (seriesMap[se.id]) { + if (seriesMap[se.id].inProgress) { + shouldContinue = false + return + } else if (seriesMap[se.id].books[0].id === libraryItem.id) { + shouldContinue = true + } + } + }) + if (!shouldContinue) { + continue; + } + + let totalWeight = 0 + + if (authorsCount > 0) { + libraryItem.media.metadata.authors.forEach((author) => { + if (topAuthorsListened[author.id]) { + totalWeight += topAuthorsListened[author.id] / authorsCount + } + }) + } + + if (genresCount > 0) { + libraryItem.media.metadata.genres.forEach((genre) => { + if (topGenresListened[genre]) { + totalWeight += topGenresListened[genre] / genresCount + } + }) + } + + if (tagsCount > 0) { + libraryItem.media.tags.forEach((tag) => { + if (topTagsListened[tag]) { + totalWeight += topTagsListened[tag] / tagsCount + } + }) + } + + if (!categoryMap.recommended.smallest || totalWeight > categoryMap.recommended.smallest) { + const libraryItemObj = { + ...libraryItem.toJSONMinified(), + weight: totalWeight + } + + const indexToPut = categoryMap.recommended.items.findIndex(i => totalWeight > i.weight) + if (indexToPut >= 0) { + categoryMap.recommended.items.splice(indexToPut, 0, libraryItemObj) + } else { + categoryMap.recommended.items.push(libraryItemObj) + } + + if (categoryMap.recommended.items.length > maxEntitiesPerShelf) { + categoryMap.recommended.items.pop() + categoryMap.recommended.smallest = categoryMap.recommended.items[categoryMap.recommended.items.length - 1].weight + } + } + } + } + // Sort series books by sequence if (categoryMap.newestSeries.items.length) { for (const seriesItem of categoryMap.newestSeries.items) {