From 831d50320ce2cf6e63b9294ccd6fd2fe65a8974a Mon Sep 17 00:00:00 2001 From: "danny.rich" Date: Mon, 3 Nov 2025 13:46:51 -0500 Subject: [PATCH 1/6] [Enhancement] Simple ratings from audible.com --- client/components/cards/BookMatchCard.vue | 18 ++++- client/components/cards/LazyBookCard.vue | 7 ++ .../components/controls/LibrarySortSelect.vue | 4 ++ client/components/modals/item/tabs/Match.vue | 34 +++++++++- client/components/ui/TextInputWithLabel.vue | 4 +- client/components/widgets/BookDetailsEdit.vue | 20 ++++++ client/pages/item/_id/index.vue | 16 +++++ server/migrations/changelog.md | 2 + server/migrations/v2.31.0-add-book-rating.js | 68 +++++++++++++++++++ server/models/Book.js | 27 ++++++++ server/models/LibraryItem.js | 1 + server/scanner/BookScanner.js | 1 + .../utils/queries/libraryItemsBookFilters.js | 3 + 13 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 server/migrations/v2.31.0-add-book-rating.js diff --git a/client/components/cards/BookMatchCard.vue b/client/components/cards/BookMatchCard.vue index 09b963c50..bff37d3e2 100644 --- a/client/components/cards/BookMatchCard.vue +++ b/client/components/cards/BookMatchCard.vue @@ -18,6 +18,10 @@

{{ $getString('LabelByAuthor', [book.author]) }}

{{ $strings.LabelNarrators }}: {{ book.narrator }}

+
+ {{ bookRatingValue >= i ? 'star' : bookRatingValue >= i - 0.5 ? 'star_half' : 'star_border' }} + {{ bookRatingValue.toFixed(1) }} +

{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}

@@ -88,7 +92,19 @@ export default { return this.$getString('LabelDurationComparisonShorter', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)]) } return this.$strings.LabelDurationComparisonExactMatch - } + }, + bookRatingValue() { + if (!this.book.rating) return 0 + if (typeof this.book.rating === 'string') { + const num = Number(this.book.rating) + return isNaN(num) ? 0 : num + } + if (typeof this.book.rating === 'object' && this.book.rating.average) { + return Number(this.book.rating.average) || 0 + } + const num = Number(this.book.rating) + return isNaN(num) ? 0 : num + }, }, methods: { selectMatch() { diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index d06f083ba..ce024b69d 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -349,6 +349,13 @@ export default { if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear]) return '\u00A0' } + if (this.orderBy === 'media.metadata.rating') { + if (this.mediaMetadata.rating) { + const ratingValue = typeof this.mediaMetadata.rating === 'object' && this.mediaMetadata.rating.average ? this.mediaMetadata.rating.average : Number(this.mediaMetadata.rating) + if (!isNaN(ratingValue) && ratingValue > 0) return `Rating: ${ratingValue.toFixed(1)}` + } + return '\u00A0' + } if (this.orderBy === 'progress') { if (!this.userProgressLastUpdated) return '\u00A0' return this.$getString('LabelLastProgressDate', [this.$formatDatetime(this.userProgressLastUpdated, this.dateFormat, this.timeFormat)]) diff --git a/client/components/controls/LibrarySortSelect.vue b/client/components/controls/LibrarySortSelect.vue index a0734d6a4..75354dfb3 100644 --- a/client/components/controls/LibrarySortSelect.vue +++ b/client/components/controls/LibrarySortSelect.vue @@ -110,6 +110,10 @@ export default { text: this.$strings.LabelPublishYear, value: 'media.metadata.publishedYear' }, + { + text: 'Rating', + value: 'media.metadata.rating' + }, { text: this.$strings.LabelAddedAt, value: 'addedAt' diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index 4b92f6cd8..b2f5c501e 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -174,6 +174,16 @@
+
+ + +
+
@@ -270,6 +280,7 @@ export default { explicit: true, asin: true, isbn: true, + rating: true, abridged: true, // Podcast specific itunesPageUrl: true, @@ -559,6 +570,11 @@ export default { if (match.narrator && !Array.isArray(match.narrator)) { match.narrator = match.narrator.split(',').map((g) => g.trim()) } + if (match.rating && typeof match.rating === 'object' && match.rating.average) { + match.rating = Number(match.rating.average) || null + } else if (match.rating !== undefined && match.rating !== null) { + match.rating = Number(match.rating) || null + } } console.log('Select Match', match) @@ -569,8 +585,19 @@ export default { var updatePayload = {} updatePayload.metadata = {} + // Always include rating if it exists in the match + if (this.selectedMatch?.rating !== undefined && this.selectedMatch?.rating !== null) { + const ratingValue = Number(this.selectedMatch.rating) + if (!isNaN(ratingValue) && ratingValue > 0) { + updatePayload.metadata.rating = ratingValue + } else if (ratingValue === 0 || isNaN(ratingValue)) { + // Set to null to remove rating + updatePayload.metadata.rating = null + } + } + for (const key in this.selectedMatchUsage) { - if (this.selectedMatchUsage[key] && this.selectedMatch[key]) { + if (this.selectedMatchUsage[key] && this.selectedMatch[key] !== undefined && this.selectedMatch[key] !== null) { if (key === 'series') { if (!Array.isArray(this.selectedMatch[key])) { console.error('Invalid series in selectedMatch', this.selectedMatch[key]) @@ -609,6 +636,11 @@ export default { updatePayload.tags = this.selectedMatch[key] } else if (key === 'itunesId') { updatePayload.metadata.itunesId = Number(this.selectedMatch[key]) + } else if (key === 'rating') { + const ratingValue = typeof this.selectedMatch[key] === 'object' && this.selectedMatch[key].average ? this.selectedMatch[key].average : Number(this.selectedMatch[key]) + if (!isNaN(ratingValue) && ratingValue > 0) { + updatePayload.metadata.rating = ratingValue + } } else { updatePayload.metadata[key] = this.selectedMatch[key] } diff --git a/client/components/ui/TextInputWithLabel.vue b/client/components/ui/TextInputWithLabel.vue index 81c5516b1..32757713f 100644 --- a/client/components/ui/TextInputWithLabel.vue +++ b/client/components/ui/TextInputWithLabel.vue @@ -6,7 +6,7 @@ {{ note }} - +
@@ -22,6 +22,8 @@ export default { default: 'text' }, min: [String, Number], + max: [String, Number], + step: [String, Number], readonly: Boolean, disabled: Boolean, inputClass: String, diff --git a/client/components/widgets/BookDetailsEdit.vue b/client/components/widgets/BookDetailsEdit.vue index db3b86ed6..4bd85264c 100644 --- a/client/components/widgets/BookDetailsEdit.vue +++ b/client/components/widgets/BookDetailsEdit.vue @@ -47,6 +47,9 @@
+
+ +
@@ -93,6 +96,7 @@ export default { language: null, isbn: null, asin: null, + rating: null, genres: [], explicit: false, abridged: false @@ -187,6 +191,7 @@ export default { if (this.$refs.descriptionInput) this.$refs.descriptionInput.blur() if (this.$refs.isbnInput) this.$refs.isbnInput.blur() if (this.$refs.asinInput) this.$refs.asinInput.blur() + if (this.$refs.ratingInput) this.$refs.ratingInput.blur() if (this.$refs.publisherInput) this.$refs.publisherInput.blur() if (this.$refs.languageInput) this.$refs.languageInput.blur() @@ -242,6 +247,10 @@ export default { for (const key in this.details) { var newValue = this.details[key] var oldValue = this.mediaMetadata[key] + // Convert rating 0 to null + if (key === 'rating' && newValue === 0) { + newValue = null + } // Key cleared out or key first populated if ((!newValue && oldValue) || (newValue && !oldValue)) { metadata[key] = newValue @@ -254,6 +263,11 @@ export default { if (!this.objectArrayEqual(newValue, oldValue)) { metadata[key] = newValue.map((v) => ({ ...v })) } + } else if (key === 'rating') { + // Always check rating changes, even if null + if (newValue != oldValue) { + metadata[key] = newValue + } } else if (newValue && newValue != oldValue) { // Intentional != metadata[key] = newValue @@ -283,6 +297,12 @@ export default { this.details.language = this.mediaMetadata.language || null this.details.isbn = this.mediaMetadata.isbn || null this.details.asin = this.mediaMetadata.asin || null + if (this.mediaMetadata.rating) { + const ratingValue = typeof this.mediaMetadata.rating === 'object' && this.mediaMetadata.rating.average ? this.mediaMetadata.rating.average : Number(this.mediaMetadata.rating) + this.details.rating = !isNaN(ratingValue) && ratingValue > 0 ? ratingValue : null + } else { + this.details.rating = null + } this.details.explicit = !!this.mediaMetadata.explicit this.details.abridged = !!this.mediaMetadata.abridged this.newTags = [...(this.media.tags || [])] diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 1d8f0f20b..a34e4487e 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -45,6 +45,11 @@

by Unknown

+
+ {{ ratingValue >= i ? 'star' : ratingValue >= i - 0.5 ? 'star_half' : 'star_border' }} + {{ ratingValue.toFixed(1) }} +
+
@@ -591,8 +591,7 @@ export default { if (!isNaN(ratingValue) && ratingValue > 0) { updatePayload.metadata.rating = ratingValue } else if (ratingValue === 0 || isNaN(ratingValue)) { - // Set to null to remove rating - updatePayload.metadata.rating = null + updatePayload.metadata.rating = undefined } } From 7377f6adaa7bcb1651d7914a8dbdbca73288e577 Mon Sep 17 00:00:00 2001 From: "danny.rich" Date: Mon, 3 Nov 2025 17:17:47 -0500 Subject: [PATCH 5/6] fix undefined in custom provider --- server/providers/CustomProviderAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js index 994eb5e6d..b4837f181 100644 --- a/server/providers/CustomProviderAdapter.js +++ b/server/providers/CustomProviderAdapter.js @@ -110,7 +110,7 @@ class CustomProviderAdapter { series: validateSeriesArray(series), language: toStringOrUndefined(language), duration: !isNaN(duration) && duration !== null ? Number(duration) : undefined, - rating: rating !== undefined && rating !== null ? (!isNaN(Number(rating)) && Number(rating) > 0 ? Number(rating) : undefined) : undefined + rating: rating !== undefined && rating !== null && !isNaN(Number(rating)) && Number(rating) > 0 ? Number(rating) : undefined } // Remove undefined values From 35862aec9b9506f069165a77b5fac82b63879a39 Mon Sep 17 00:00:00 2001 From: "danny.rich" Date: Mon, 3 Nov 2025 17:26:43 -0500 Subject: [PATCH 6/6] bit more clean up --- client/components/modals/item/tabs/Match.vue | 15 ++++----------- server/scanner/Scanner.js | 12 +++++++----- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index f76f3bab2..193a7eb21 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -475,6 +475,7 @@ export default { explicit: true, asin: true, isbn: true, + rating: true, abridged: true, // Podcast specific itunesPageUrl: true, @@ -585,16 +586,6 @@ export default { var updatePayload = {} updatePayload.metadata = {} - // Always include rating if it exists in the match - if (this.selectedMatch?.rating !== undefined && this.selectedMatch?.rating !== null) { - const ratingValue = Number(this.selectedMatch.rating) - if (!isNaN(ratingValue) && ratingValue > 0) { - updatePayload.metadata.rating = ratingValue - } else if (ratingValue === 0 || isNaN(ratingValue)) { - updatePayload.metadata.rating = undefined - } - } - for (const key in this.selectedMatchUsage) { if (this.selectedMatchUsage[key] && this.selectedMatch[key] !== undefined && this.selectedMatch[key] !== null) { if (key === 'series') { @@ -636,9 +627,11 @@ export default { } else if (key === 'itunesId') { updatePayload.metadata.itunesId = Number(this.selectedMatch[key]) } else if (key === 'rating') { - const ratingValue = typeof this.selectedMatch[key] === 'object' && this.selectedMatch[key].average ? this.selectedMatch[key].average : Number(this.selectedMatch[key]) + const ratingValue = Number(this.selectedMatch[key]) if (!isNaN(ratingValue) && ratingValue > 0) { updatePayload.metadata.rating = ratingValue + } else if (ratingValue === 0 || isNaN(ratingValue)) { + updatePayload.metadata.rating = undefined } } else { updatePayload.metadata[key] = this.selectedMatch[key] diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index a986d2252..bd1d4091a 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -231,15 +231,17 @@ class Scanner { updatePayload[key] = tagsArray } } else if (key === 'rating') { - // Normalize rating: convert object with average to number, or number to number + // Normalize rating (handle object format {average: 4.5} from some providers) let ratingValue = matchData[key] if (ratingValue && typeof ratingValue === 'object' && ratingValue.average) { - ratingValue = Number(ratingValue.average) || null + ratingValue = Number(ratingValue.average) } else if (ratingValue !== undefined && ratingValue !== null) { - ratingValue = Number(ratingValue) || null + ratingValue = Number(ratingValue) + } else { + ratingValue = null } - if (ratingValue === 0) ratingValue = null - if ((!libraryItem.media.rating || options.overrideDetails) && ratingValue !== null && !isNaN(ratingValue) && ratingValue > 0) { + // 0 = no rating, only update if valid rating > 0 + if (ratingValue !== null && !isNaN(ratingValue) && ratingValue > 0 && (!libraryItem.media.rating || options.overrideDetails)) { updatePayload[key] = ratingValue } else if (ratingValue === null && libraryItem.media.rating && options.overrideDetails) { updatePayload[key] = null