mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-06 16:09:46 +00:00
feat: Add reset metadata capability
This commit is contained in:
parent
98ce898f41
commit
feb87e20d1
5 changed files with 167 additions and 0 deletions
37
artifacts/2026-02-14/implementation_plan.md
Normal file
37
artifacts/2026-02-14/implementation_plan.md
Normal 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.
|
||||||
46
artifacts/2026-02-14/reset_metadata_specification.md
Normal file
46
artifacts/2026-02-14/reset_metadata_specification.md
Normal 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.
|
||||||
|
|
@ -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-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" />
|
<div class="grow" />
|
||||||
|
|
||||||
<!-- desktop -->
|
<!-- desktop -->
|
||||||
|
|
@ -37,6 +41,7 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
resettingProgress: false,
|
resettingProgress: false,
|
||||||
|
resetting: false,
|
||||||
isScrollable: false,
|
isScrollable: false,
|
||||||
rescanning: false,
|
rescanning: false,
|
||||||
quickMatching: false
|
quickMatching: false
|
||||||
|
|
@ -141,6 +146,39 @@ export default {
|
||||||
this.rescanning = false
|
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() {
|
async saveAndClose() {
|
||||||
const wasUpdated = await this.save()
|
const wasUpdated = await this.save()
|
||||||
if (wasUpdated !== null) this.$emit('close')
|
if (wasUpdated !== null) this.$emit('close')
|
||||||
|
|
|
||||||
|
|
@ -755,6 +755,51 @@ class LibraryItemController {
|
||||||
res.json(matchResult)
|
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
|
* POST: /api/items/batch/delete
|
||||||
* Batch delete library items. Will delete from database and file system if hard delete is requested.
|
* Batch delete library items. Will delete from database and file system if hard delete is requested.
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ class ApiRouter {
|
||||||
this.router.patch('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.updateCover.bind(this))
|
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.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/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', 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.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))
|
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue