feat: Add reset metadata capability

This commit is contained in:
Tiberiu Ichim 2026-02-14 21:57:54 +02:00
parent 98ce898f41
commit feb87e20d1
5 changed files with 167 additions and 0 deletions

View file

@ -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/<id>/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.

View file

@ -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/<id>/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.

View file

@ -13,6 +13,10 @@
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="isLibraryScanning" color="bg-bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
<ui-tooltip v-if="userIsAdminOrUp && !isFile" text="Reset metadata to file tags" direction="bottom" class="ml-2">
<ui-btn :loading="resetting" :disabled="isLibraryScanning" color="bg-error" type="button" class="h-full" small @click.stop.prevent="resetMetadata">Reset</ui-btn>
</ui-tooltip>
<div class="grow" />
<!-- desktop -->
@ -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')

View file

@ -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.

View file

@ -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))