diff --git a/artifacts/2026-02-14/implementation_plan.md b/artifacts/2026-02-14/implementation_plan.md new file mode 100644 index 000000000..21173690f --- /dev/null +++ b/artifacts/2026-02-14/implementation_plan.md @@ -0,0 +1,37 @@ +# Reset Metadata Feature Implementation Plan + +## Objective +Implement a "Reset Metadata" feature that allows users to reset a library item's metadata to its original state derived from the file system (tags, folder structure, OPF files), effectively ignoring or removing any manual edits stored in the database or `metadata.json` files. + +## Rationale +Users may encounter situations where a library item is matched to the wrong book, or the underlying files have changed (e.g., replaced with a different audiobook version). The existing "ReScan" functionality often preserves existing metadata (especially if `metadata.json` exists) to prevent data loss, which makes it difficult to force a full refresh from the files. A dedicated "Reset" action is needed. + +## Implementation Steps + +### 1. Backend Implementation +**File:** `server/controllers/LibraryItemController.js` +- **Method:** `resetMetadata(req, res)` +- **Logic:** + 1. Check for update permissions (`req.user.canUpdate`). + 2. Identify and delete `metadata.json` from the server's metadata directory (`/metadata/items//metadata.json`). + 3. Identify and delete `metadata.json` from the item's local folder (if `storeMetadataWithItem` is enabled and it exists). + 4. Set `media.coverPath` to `null` in the database to force a re-evaluation of the cover image (checking embedded art or `cover.jpg` in folder). + 5. Trigger `LibraryItemScanner.scanLibraryItem(id)` to re-process the item from scratch using the remaining sources (Audio Tags, OPF, NFO, Folder Structure). + 6. Return the updated library item. + +**File:** `server/routers/ApiRouter.js` +- **Route:** `POST /api/items/:id/reset-metadata` +- **Middleware:** Authenticated, Item Access, Update Permission. + +### 2. Frontend Implementation +**File:** `client/components/modals/item/tabs/Details.vue` +- **UI:** Add a "Reset" button to the "Details" tab in the edit modal, located next to the "ReScan" button. +- **Style:** Use `bg-error` (red) to indicate a destructive action. +- **Logic:** + 1. On click, show a confirmation dialog explaining the action. + 2. Call the `resetMetadata` API endpoint. + 3. On success, show a toast notification and update the view. + +## Verification +- **Test Case:** Open an audiobook with manually edited metadata (e.g., changed title). Click "Reset". The title should revert to what is defined in the audio file tags or folder name. +- **Test Case:** Open an audiobook with `metadata.json` present. Click "Reset". The `metadata.json` file should be deleted and metadata refreshed. diff --git a/artifacts/2026-02-14/reset_metadata_specification.md b/artifacts/2026-02-14/reset_metadata_specification.md new file mode 100644 index 000000000..35cf4c1cd --- /dev/null +++ b/artifacts/2026-02-14/reset_metadata_specification.md @@ -0,0 +1,46 @@ +# Reset Metadata Feature Specification + +## Overview +This document outlines the implementation of the "Reset Metadata" feature, designed to allow users to reset a library item's metadata to its original state derived from the file system (tags, folder structure, OPF files), effectively ignoring or removing any manual edits stored in the database or `metadata.json` files. + +## Date +2026-02-14 + +## Rationale +Users may encounter situations where a library item is matched to the wrong book, or the underlying files have changed (e.g., replaced with a different audiobook version). The existing "ReScan" functionality often preserves existing metadata (especially if `metadata.json` exists) to prevent data loss, which makes it difficult to force a full refresh from the files. A dedicated "Reset" action is needed. + +## Detailed Implementation + +### 1. Backend Implementation + +#### Server Controller: `LibraryItemController.js` +- **Method:** `resetMetadata(req, res)` +- **Logic:** + 1. **Permission Check:** Verifies if the user has update permissions (`req.user.canUpdate`). Returns 403 if not. + 2. **Server Metadata Removal:** Identifies and deletes the `metadata.json` file from the server's metadata directory (`/metadata/items//metadata.json`) if it exists. + 3. **Local Metadata Removal:** Identifies and deletes the `metadata.json` file from the item's local folder path (if `storeMetadataWithItem` is enabled and the file exists). + 4. **Database Update (Cover):** Sets `media.coverPath` to `null` in the database. This forces a re-evaluation of the cover image (checking embedded art or `cover.jpg` in folder) during the subsequent scan. + 5. **Re-Scan:** Triggers `LibraryItemScanner.scanLibraryItem(id)` to re-process the item from scratch using the remaining sources (Audio Tags, OPF, NFO, Folder Structure). + 6. **Response:** Returns the updated library item in JSON format. + +#### API Router: `ApiRouter.js` +- **Route:** `POST /api/items/:id/reset-metadata` +- **Middleware:** Applies authentication, item access checks, and update permission verification (`LibraryItemController.middleware`). +- **Handler:** Maps to the `LibraryItemController.resetMetadata` method. + +### 2. Frontend Implementation + +#### Vue Component: `Details.vue` +- **Location:** `client/components/modals/item/tabs/Details.vue` +- **UI Element:** Added a "Reset" button to the "Details" tab in the edit modal, located next to the existing "ReScan" button. +- **Styling:** Used `bg-error` (red) for the button to indicate a destructive action. +- **Interactivity:** + 1. **Click Handler:** `resetMetadata()` triggers a confirmation dialog (`globals/setConfirmPrompt`) explaining the action: "Are you sure you want to reset metadata? This will remove the metadata file and re-scan the item from files." + 2. **Action Handler:** `runResetMetadata()` calls the `POST /api/items/:id/reset-metadata` endpoint. + 3. **Feedback:** On success, a toast notification "Metadata reset successfully" is displayed. On failure, an error toast is shown. + 4. **State Management:** Uses a `resetting` flag to show a loading state on the button while the request is processing. + +## Verification Scenarios +1. **Manual Edit Reversion:** Open an audiobook where the title was manually changed. Click "Reset". Verify that the title reverts to the value found in the audio file tags or folder name. +2. **Metadata File Deletion:** Open an audiobook that has a `metadata.json` file present. Click "Reset". Verify that the `metadata.json` file is deleted from the filesystem and the metadata is refreshed. +3. **Cover Reset:** Ensure that triggering a reset also clears the cover path, causing the scanner to look for a cover again. diff --git a/client/components/modals/item/tabs/Details.vue b/client/components/modals/item/tabs/Details.vue index e099f2bb0..e562fc611 100644 --- a/client/components/modals/item/tabs/Details.vue +++ b/client/components/modals/item/tabs/Details.vue @@ -13,6 +13,10 @@ {{ $strings.ButtonReScan }} + + Reset + +
@@ -37,6 +41,7 @@ export default { data() { return { resettingProgress: false, + resetting: false, isScrollable: false, rescanning: false, quickMatching: false @@ -141,6 +146,39 @@ export default { this.rescanning = false }) }, + resetMetadata() { + const payload = { + message: 'Are you sure you want to reset metadata? This will remove the metadata file and re-scan the item from files.', + callback: (confirmed) => { + if (confirmed) { + this.runResetMetadata() + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + runResetMetadata() { + this.resetting = true + this.$axios + .$post(`/api/items/${this.libraryItemId}/reset-metadata`) + .then((data) => { + this.resetting = false + this.$toast.success('Metadata reset successfully') + // Update the library item in the parent component + if (data) { + this.$emit('submit', { updatePayload: {}, hasChanges: false }) // Just to close or maybe I should trigger an update event + // Currently saveAndClose emits 'close'. + // We probably want to just update the local view or close. + // If I close, the user sees the updated card in the library. + } + }) + .catch((error) => { + console.error('Failed to reset metadata', error) + this.$toast.error('Failed to reset metadata') + this.resetting = false + }) + }, async saveAndClose() { const wasUpdated = await this.save() if (wasUpdated !== null) this.$emit('close') diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index a2406663c..c1ae5c5eb 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -755,6 +755,51 @@ class LibraryItemController { res.json(matchResult) } + /** + * POST /api/items/:id/reset-metadata + * + * @param {LibraryItemControllerRequest} req + * @param {Response} res + */ + async resetMetadata(req, res) { + if (!req.user.canUpdate) { + Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to reset metadata without permission`) + return res.sendStatus(403) + } + + if (global.MetadataPath) { + const metadataPath = Path.join(global.MetadataPath, 'items', req.libraryItem.id, 'metadata.json') + if (await fs.pathExists(metadataPath)) { + Logger.info(`[LibraryItemController] Removing metadata file at "${metadataPath}"`) + await fs.remove(metadataPath) + } + } + + if (req.libraryItem.path && !req.libraryItem.isFile) { + const localMetadataPath = Path.join(req.libraryItem.path, 'metadata.json') + if (await fs.pathExists(localMetadataPath)) { + Logger.info(`[LibraryItemController] Removing local metadata file at "${localMetadataPath}"`) + await fs.remove(localMetadataPath) + } + } + + // Clear cover path to force re-scan of cover + if (req.libraryItem.media.coverPath) { + req.libraryItem.media.coverPath = null + await req.libraryItem.media.save() + } + + // Trigger a scan ensuring we don't rely on cache/timestamps if possible + // scanLibraryItem checks mtime but since we deleted metadata.json which might have been the source, + // the "comparison" logic in BookScanner should now fallback to other sources (tags/folder). + // If those sources yield different data than DB, it updates. + const result = await LibraryItemScanner.scanLibraryItem(req.libraryItem.id) + + // Respond with updated item + await req.libraryItem.reload() + res.json(req.libraryItem.toOldJSONExpanded()) + } + /** * POST: /api/items/batch/delete * Batch delete library items. Will delete from database and file system if hard delete is requested. diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index a200ddf92..cb312e52e 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -118,6 +118,7 @@ class ApiRouter { this.router.patch('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.updateCover.bind(this)) this.router.delete('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.removeCover.bind(this)) this.router.post('/items/:id/match', LibraryItemController.middleware.bind(this), LibraryItemController.match.bind(this)) + this.router.post('/items/:id/reset-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.resetMetadata.bind(this)) this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this)) this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this)) this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))