From b8b3a2049822a65c2445726d332c55dcb1bc00fa Mon Sep 17 00:00:00 2001 From: Quentin King Date: Sat, 3 Jan 2026 10:34:05 -0600 Subject: [PATCH] feat: add UI for editing series Audible ASIN - Create AsinInput.vue component for ASIN input with URL extraction - Update EditSeriesInputInnerModal to include ASIN field - Update SeriesInputWidget to fetch and save ASIN data - Add SeriesController PATCH endpoint for updating ASIN - Add localization strings for ASIN-related messages The AsinInput component automatically extracts ASINs from pasted Audible URLs and provides validation feedback. --- .gitignore | 3 + .../modals/EditSeriesInputInnerModal.vue | 60 ++++++- client/components/ui/AsinInput.vue | 155 ++++++++++++++++++ .../components/widgets/SeriesInputWidget.vue | 61 ++++++- client/strings/en-us.json | 3 + docs/objects/entities/Series.yaml | 12 ++ package-lock.json | 4 +- package.json | 2 +- server/controllers/SeriesController.js | 38 ++++- 9 files changed, 322 insertions(+), 16 deletions(-) create mode 100644 client/components/ui/AsinInput.vue diff --git a/.gitignore b/.gitignore index 12ebec1c2..6256bb889 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ sw.* .idea/* tailwind.compiled.css tailwind.config.js +dev.sh +docs-backend-guide.md +docs-enhancements.md diff --git a/client/components/modals/EditSeriesInputInnerModal.vue b/client/components/modals/EditSeriesInputInnerModal.vue index bd568321f..8d749f5ef 100644 --- a/client/components/modals/EditSeriesInputInnerModal.vue +++ b/client/components/modals/EditSeriesInputInnerModal.vue @@ -14,6 +14,16 @@ +
+
+
+ + + help + +
+
+
{{ error }}
{{ $strings.ButtonSubmit }} @@ -45,7 +55,8 @@ export default { return { el: null, content: null, - error: null + error: null, + seriesAsin: '' } }, watch: { @@ -55,6 +66,21 @@ export default { } else { this.setHide() } + }, + selectedSeries: { + handler(newVal) { + if (!this.show) return + this.seriesAsin = newVal?.audibleSeriesAsin || '' + }, + deep: true + }, + // Watch for series name changes to auto-populate ASIN when selecting existing series + 'selectedSeries.name': { + async handler(newName) { + if (!this.show || !newName || !this.isNewSeries) return + // Check if this matches an existing series in the library + await this.fetchSeriesAsinByName(newName) + } } }, computed: { @@ -77,6 +103,22 @@ export default { this.$refs.sequenceInput.setFocus() } }, + async fetchSeriesAsinByName(seriesName) { + try { + const libraryId = this.$store.state.libraries.currentLibraryId + const series = this.$store.state.libraries.filterData?.series || [] + const matchingSeries = series.find((se) => se.name.toLowerCase() === seriesName.toLowerCase()) + if (!matchingSeries) return + + // Fetch full series data to get ASIN + const fullSeries = await this.$axios.$get(`/api/libraries/${libraryId}/series/${matchingSeries.id}`) + if (fullSeries?.audibleSeriesAsin) { + this.seriesAsin = fullSeries.audibleSeriesAsin + } + } catch (error) { + console.error('Failed to fetch series ASIN:', error) + } + }, setInputFocus() { if (this.isNewSeries) { // Focus on series input if new series @@ -102,7 +144,18 @@ export default { return } - this.$emit('submit') + // Validate ASIN format if provided + if (this.seriesAsin && this.seriesAsin.trim()) { + const asin = this.seriesAsin.trim().toUpperCase() + if (!/^[A-Z0-9]{10}$/.test(asin)) { + this.error = this.$strings.MessageInvalidAsin + return + } + this.seriesAsin = asin + } + + // Pass ASIN along with submit + this.$emit('submit', { audibleSeriesAsin: this.seriesAsin || null }) }, clickClose() { this.show = false @@ -114,6 +167,9 @@ export default { }, setShow() { this.error = null + // Load existing ASIN from the series if it exists + this.seriesAsin = this.selectedSeries?.audibleSeriesAsin || '' + if (!this.el || !this.content) { this.init() } diff --git a/client/components/ui/AsinInput.vue b/client/components/ui/AsinInput.vue new file mode 100644 index 000000000..6041e1a25 --- /dev/null +++ b/client/components/ui/AsinInput.vue @@ -0,0 +1,155 @@ + + + diff --git a/client/components/widgets/SeriesInputWidget.vue b/client/components/widgets/SeriesInputWidget.vue index 3dab0605a..25c1e9b29 100644 --- a/client/components/widgets/SeriesInputWidget.vue +++ b/client/components/widgets/SeriesInputWidget.vue @@ -56,12 +56,35 @@ export default { var _series = this.seriesItems.find((se) => se.id === series.id) if (!_series) return - this.selectedSeries = { - ..._series + // If this is an existing series (not new), fetch the full series data to get ASIN + if (!_series.id.startsWith('new-')) { + this.fetchSeriesData(_series.id).then((fullSeries) => { + this.selectedSeries = { + ..._series, + audibleSeriesAsin: fullSeries?.audibleSeriesAsin || '' + } + this.originalSeriesSequence = _series.sequence + this.showSeriesForm = true + }) + } else { + this.selectedSeries = { + ..._series, + // Map 'asin' from match data to 'audibleSeriesAsin' for the edit form + audibleSeriesAsin: _series.asin || _series.audibleSeriesAsin || '' + } + this.originalSeriesSequence = _series.sequence + this.showSeriesForm = true + } + }, + async fetchSeriesData(seriesId) { + try { + const libraryId = this.$store.state.libraries.currentLibraryId + const series = await this.$axios.$get(`/api/libraries/${libraryId}/series/${seriesId}`) + return series + } catch (error) { + console.error('Failed to fetch series data:', error) + return null } - - this.originalSeriesSequence = _series.sequence - this.showSeriesForm = true }, addNewSeries() { this.selectedSeries = { @@ -73,7 +96,7 @@ export default { this.originalSeriesSequence = null this.showSeriesForm = true }, - submitSeriesForm() { + submitSeriesForm(formData) { if (!this.selectedSeries.name) { this.$toast.error('Must enter a series') return @@ -96,6 +119,11 @@ export default { var selectedSeriesCopy = { ...this.selectedSeries } selectedSeriesCopy.displayName = selectedSeriesCopy.sequence ? `${selectedSeriesCopy.name} #${selectedSeriesCopy.sequence}` : selectedSeriesCopy.name + // Store ASIN for later update (after book is saved and series exists) + if (formData?.audibleSeriesAsin !== undefined) { + selectedSeriesCopy.audibleSeriesAsin = formData.audibleSeriesAsin + } + var seriesCopy = this.seriesItems.map((v) => ({ ...v })) if (existingSeriesIndex >= 0) { seriesCopy.splice(existingSeriesIndex, 1, selectedSeriesCopy) @@ -105,7 +133,28 @@ export default { this.seriesItems = seriesCopy } + // If this is an existing series (not new), update the ASIN immediately + if (!this.selectedSeries.id.startsWith('new-') && formData?.audibleSeriesAsin !== undefined) { + this.updateSeriesAsin(this.selectedSeries.id, formData.audibleSeriesAsin) + } + this.showSeriesForm = false + }, + async updateSeriesAsin(seriesId, asin) { + // Skip API call if ASIN is empty - backend safeguard prevents clearing anyway, + // but this avoids unnecessary network requests + if (!asin) { + return + } + try { + await this.$axios.$patch(`/api/series/${seriesId}`, { + audibleSeriesAsin: asin + }) + this.$toast.success(this.$strings.ToastSeriesUpdateSuccess) + } catch (error) { + console.error('Failed to update series ASIN:', error) + this.$toast.error(this.$strings.ToastSeriesUpdateFailed) + } } } } diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..de550bba6 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -576,6 +576,7 @@ "LabelSequence": "Sequence", "LabelSerial": "Serial", "LabelSeries": "Series", + "LabelSeriesAsin": "Audible Series ASIN", "LabelSeriesName": "Series Name", "LabelSeriesProgress": "Series Progress", "LabelServerLogLevel": "Server Log Level", @@ -737,6 +738,7 @@ "MessageAddToPlayerQueue": "Add to player queue", "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.", + "MessageAsinExtractedFromUrl": "ASIN extracted from URL", "MessageAuthenticationLegacyTokenWarning": "Legacy API tokens will be removed in the future. Use API Keys instead.", "MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.", "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.", @@ -938,6 +940,7 @@ "MessageUploaderItemFailed": "Failed to upload", "MessageUploaderItemSuccess": "Successfully Uploaded!", "MessageUploading": "Uploading...", + "MessageValidAsinFormat": "Valid ASIN format", "MessageValidCronExpression": "Valid cron expression", "MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings", "MessageXLibraryIsEmpty": "{0} Library is empty!", diff --git a/docs/objects/entities/Series.yaml b/docs/objects/entities/Series.yaml index ef35a5b35..9f671dc90 100644 --- a/docs/objects/entities/Series.yaml +++ b/docs/objects/entities/Series.yaml @@ -14,6 +14,12 @@ components: type: string nullable: true example: The Sword of Truth is a series of twenty one epic fantasy novels written by Terry Goodkind. + audibleSeriesAsin: + description: The Audible ASIN (Amazon Standard Identification Number) for this series. Used for metadata lookups. Will be null if not set. + type: string + nullable: true + pattern: '^[A-Z0-9]{10}$' + example: B08G9PRS1K sequence: description: The position in the series the book is. type: string @@ -45,6 +51,8 @@ components: $ref: '#/components/schemas/seriesName' description: $ref: '#/components/schemas/seriesDescription' + audibleSeriesAsin: + $ref: '#/components/schemas/audibleSeriesAsin' addedAt: $ref: '../../schemas.yaml#/components/schemas/addedAt' updatedAt: @@ -73,6 +81,10 @@ components: $ref: '#/components/schemas/seriesId' name: $ref: '#/components/schemas/seriesName' + description: + $ref: '#/components/schemas/seriesDescription' + audibleSeriesAsin: + $ref: '#/components/schemas/audibleSeriesAsin' addedAt: $ref: '../../schemas.yaml#/components/schemas/addedAt' nameIgnorePrefix: diff --git a/package-lock.json b/package-lock.json index 08707893d..e07fba51d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.32.1", + "version": "2.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.32.1", + "version": "2.33.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 3ee3fb391..3108b5170 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.32.1", + "version": "2.33.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", diff --git a/server/controllers/SeriesController.js b/server/controllers/SeriesController.js index 21c93f332..08100596d 100644 --- a/server/controllers/SeriesController.js +++ b/server/controllers/SeriesController.js @@ -62,17 +62,37 @@ class SeriesController { } /** - * TODO: Currently unused in the client, should check for duplicate name + * PATCH /api/series/:id + * Update series metadata (name, description, audibleSeriesAsin) + * + * TODO: should check for duplicate name * * @param {SeriesControllerRequest} req * @param {Response} res */ async update(req, res) { - const keysToUpdate = ['name', 'description'] + const keysToUpdate = ['name', 'description', 'audibleSeriesAsin'] const payload = {} for (const key of keysToUpdate) { - if (req.body[key] !== undefined && typeof req.body[key] === 'string') { - payload[key] = req.body[key] + if (req.body[key] !== undefined) { + const value = req.body[key] + + // audibleSeriesAsin accepts null, empty string, or string + // Model hook will normalize (extract from URL, uppercase) and validate + // SAFEGUARD: null/empty values will NOT clear an existing ASIN (prevents accidental data loss) + if (key === 'audibleSeriesAsin') { + if (value === null || value === '') { + // Skip adding to payload if empty - existing ASIN will be preserved + // To explicitly clear, user must delete/recreate series or use a special endpoint + continue + } else if (typeof value === 'string') { + payload[key] = value // Model hook will normalize & validate + } else { + return res.status(400).send('audibleSeriesAsin must be a string or null') + } + } else if (typeof value === 'string') { + payload[key] = value + } } } if (!Object.keys(payload).length) { @@ -80,7 +100,15 @@ class SeriesController { } req.series.set(payload) if (req.series.changed()) { - await req.series.save() + try { + await req.series.save() + } catch (error) { + // Handle model-level validation errors (e.g., invalid ASIN format) + if (error.message?.includes('ASIN') || error.message?.includes('audibleSeriesAsin')) { + return res.status(400).send(error.message) + } + throw error // Re-throw unexpected errors + } SocketAuthority.emitter('series_updated', req.series.toOldJSON()) } res.json(req.series.toOldJSON())