diff --git a/artifacts/2026-02-15/batch_reset.md b/artifacts/2026-02-15/batch_reset.md new file mode 100644 index 000000000..7241add43 --- /dev/null +++ b/artifacts/2026-02-15/batch_reset.md @@ -0,0 +1,46 @@ +# Batch Reset Metadata Specification + +## Overview +The "Batch Reset Metadata" feature allows users to reset the metadata for multiple selected library items (books, podcasts, etc.) at once. This action reverts the items' metadata to what is found in the file system (tags, folder structure, OPF files), removing any manual edits stored in the database or `metadata.json` files. + +## Date +2026-02-15 + +## User Interface + +### Frontend +- **Component**: `client/components/app/Appbar.vue` +- **Trigger**: Context menu in the selection mode app bar (when multiple items are selected). +- **Visibility**: + - Available when multiple items are selected. + - Only available if the user has "Update" permissions. +- **Interaction**: + - Clicking "Reset Metadata" triggers a confirmation dialog. + - **Confirmation Message**: "Are you sure you want to reset metadata for ${n} items? This will remove metadata files and re-scan the items from files." + - **Action**: detailed in Backend Logic. + - **Feedback**: + - Success: Toast notification "Batch reset metadata successful". + - Failure: Error toast notification. + +## Backend Logic + +### Controller +- **Controller**: `LibraryItemController` +- **Method**: `batchResetMetadata(req, res)` +- **Logic**: + 1. **Permission Check**: Verify `req.user.canUpdate`. Return 403 if not. + 2. **Input Validation**: Check `libraryItemIds` array in body. + 3. **Retrieve Items**: Fetch items by IDs. + 4. **Process Loop**: Iterate through each item: + - **Remove Server Metadata**: Delete `/metadata/items//metadata.json` if exists. + - **Remove Local Metadata**: Delete `/metadata.json` if exists (and not a single file item). + - **Reset Cover**: Set `media.coverPath` to `null`. + - **Re-Scan**: Trigger `LibraryItemScanner.scanLibraryItem(id)`. + 5. **Response**: JSON object `{ success: true, results: [...] }`. + +### API Router +- **Route**: `POST /api/items/batch/reset-metadata` +- **Handler**: `LibraryItemController.batchResetMetadata` + +## Artifacts +- This specification is saved as `artifacts/2026-02-15/batch_reset.md`. diff --git a/artifacts/2026-02-15/batch_reset_implementation_status.md b/artifacts/2026-02-15/batch_reset_implementation_status.md new file mode 100644 index 000000000..26f6f02b7 --- /dev/null +++ b/artifacts/2026-02-15/batch_reset_implementation_status.md @@ -0,0 +1,19 @@ +# Batch Reset Metadata Implementation Status + +## Completed +- [x] Specification created (`artifacts/2026-02-15/batch_reset.md`) +- [x] Backend logic implemented in `server/controllers/LibraryItemController.js` (`batchResetMetadata`) +- [x] API route registered in `server/routers/ApiRouter.js` (`POST /api/items/batch/reset-metadata`) +- [x] Frontend UI updated in `client/components/app/Appbar.vue` (Menu item + Handler) + +## Verification +- Syntax checked for backend files. +- Manual verification required by user: + 1. Select multiple books/library items. + 2. Click the context menu (three dots) in the selection bar. + 3. Click "Reset Metadata". + 4. Confirm the dialog. + 5. Verify items are re-scanned and metadata reset. + +## Note +- Localization string for confirmation message is currently hardcoded in English. diff --git a/artifacts/2026-02-15/select_all.md b/artifacts/2026-02-15/select_all.md new file mode 100644 index 000000000..8135f65c3 --- /dev/null +++ b/artifacts/2026-02-15/select_all.md @@ -0,0 +1,36 @@ +# Select All Keyboard Shortcut Specification + +## Overview +Enable `Ctrl+A` (or `Cmd+A` on macOS) to select all items in the library listing screen. + +## Date +2026-02-15 + +## User Interface +- **Component**: `client/components/app/LazyBookshelf.vue` +- **Trigger**: Global `keydown` event listener active when the bookshelf is mounted. +- **Shortcut**: `Ctrl+A` / `Cmd+A`. +- **Behavior**: + - Selects all currently loaded items in the bookshelf. + - Sets a `isSelectAll` flag that automatically selects newly loaded items as the user scrolls. + - Updates the "Selection Mode" UI with the total count of selected items. + - Clicking/Deselecting an individual item while `isSelectAll` is active will toggle off the `isSelectAll` persistent state (but keep existing selections). + +## Implementation Details + +### Vuex Store +- **File**: `client/store/globals.js` +- **Mutation**: `addBatchMediaItemsSelected` +- **Purpose**: Efficiently add a large number of items to the `selectedMediaItems` array without duplicates. + +### LazyBookshelf Component +- **Methods**: + - `handleKeyDown(e)`: Detects the shortcut and calls `selectAll()`. + - `selectAll()`: Iterates through loaded `entities`, builds media item objects, and commits them to the store. + - `mountEntities()` Extension: Check `isSelectAll` flag and auto-select items as they are rendered. +- **Events**: + - Listen for `keydown` on `window`. + - Handle `bookshelf_clear_selection` event to reset `isSelectAll` flag. + +## Artifacts +- This specification is saved as `artifacts/2026-02-15/select_all.md`. diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 0d2e6bad0..efec2c8a1 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -186,6 +186,11 @@ export default { action: 'rescan' }) + options.push({ + text: 'Reset Metadata', + action: 'reset-metadata' + }) + // The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440 // + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains if (this.selectedMediaItems.length <= 40) { @@ -261,6 +266,8 @@ export default { this.batchMerge() } else if (action === 'consolidate') { this.batchConsolidate() + } else if (action === 'reset-metadata') { + this.batchResetMetadata() } }, batchConsolidate() { @@ -294,6 +301,34 @@ export default { } this.$store.commit('globals/setConfirmPrompt', payload) }, + batchResetMetadata() { + const payload = { + message: `Are you sure you want to reset metadata for ${this.numMediaItemsSelected} items? This will remove metadata files and re-scan the items from files.`, + callback: (confirmed) => { + if (confirmed) { + this.$store.commit('setProcessingBatch', true) + this.$axios + .$post('/api/items/batch/reset-metadata', { + libraryItemIds: this.selectedMediaItems.map((i) => i.id) + }) + .then(() => { + this.$toast.success('Batch reset metadata successful') + this.cancelSelectionMode() + }) + .catch((error) => { + console.error('Batch reset metadata failed', error) + const errorMsg = error.response?.data || 'Batch reset metadata failed' + this.$toast.error(errorMsg) + }) + .finally(() => { + this.$store.commit('setProcessingBatch', false) + }) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, batchMerge() { const payload = { message: this.$strings.MessageConfirmBatchMerge, diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index d1be0d8c3..3301b5d45 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -83,7 +83,8 @@ export default { lastTimestamp: 0, postScrollTimeout: null, currFirstEntityIndex: -1, - currLastEntityIndex: -1 + currLastEntityIndex: -1, + isSelectAll: false } }, watch: { @@ -246,17 +247,48 @@ export default { } }, clearSelectedEntities() { + this.isSelectAll = false this.updateBookSelectionMode(false) this.isSelectionMode = false }, + selectAll() { + if (this.entityName !== 'items' && this.entityName !== 'series-books' && this.entityName !== 'collections' && this.entityName !== 'playlists') { + return + } + + this.isSelectAll = true + this.isSelectionMode = true + + const itemsToSelect = [] + this.entities.forEach((entity) => { + if (entity && !entity.collapsedSeries) { + const mediaItem = { + id: entity.id, + libraryId: entity.libraryId, + mediaType: entity.mediaType, + hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length) + } + itemsToSelect.push(mediaItem) + } + }) + + if (itemsToSelect.length) { + this.$store.commit('globals/addBatchMediaItemsSelected', itemsToSelect) + } + + this.updateBookSelectionMode(true) + }, selectEntity(entity, shiftKey) { if (this.entityName === 'items' || this.entityName === 'series-books') { const indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id) const lastLastItemIndexSelected = this.lastItemIndexSelected - if (!this.selectedMediaItems.some((i) => i.id === entity.id)) { + const alreadySelected = this.selectedMediaItems.some((i) => i.id === entity.id) + + if (!alreadySelected) { this.lastItemIndexSelected = indexOf } else { this.lastItemIndexSelected = -1 + this.isSelectAll = false // Deselecting an item turns off "Select All" mode } if (shiftKey && lastLastItemIndexSelected >= 0) { @@ -322,7 +354,11 @@ export default { updateBookSelectionMode(isSelectionMode) { for (const key in this.entityComponentRefs) { if (this.entityIndexesMounted.includes(Number(key))) { - this.entityComponentRefs[key].setSelectionMode(isSelectionMode) + const component = this.entityComponentRefs[key] + component.setSelectionMode(isSelectionMode) + if (isSelectionMode && this.isSelectAll) { + component.selected = true + } } } if (!isSelectionMode) { @@ -780,8 +816,19 @@ export default { windowResize() { this.executeRebuild() }, + handleKeyDown(e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + // Only trigger if no input/textarea is focused + if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) { + return + } + e.preventDefault() + this.selectAll() + } + }, initListeners() { window.addEventListener('resize', this.windowResize) + window.addEventListener('keydown', this.handleKeyDown) this.$nextTick(() => { var bookshelf = document.getElementById('bookshelf') @@ -817,6 +864,7 @@ export default { }, removeListeners() { window.removeEventListener('resize', this.windowResize) + window.removeEventListener('keydown', this.handleKeyDown) var bookshelf = document.getElementById('bookshelf') if (bookshelf) { bookshelf.removeEventListener('scroll', this.scroll) diff --git a/client/mixins/bookshelfCardsHelpers.js b/client/mixins/bookshelfCardsHelpers.js index f1571f70e..4fddbfb9c 100644 --- a/client/mixins/bookshelfCardsHelpers.js +++ b/client/mixins/bookshelfCardsHelpers.js @@ -140,6 +140,17 @@ export default { instance.setSelectionMode(true) if ((instance.libraryItemId && this.selectedMediaItems.some((i) => i.id === instance.libraryItemId)) || this.isSelectAll) { instance.selected = true + + if (this.isSelectAll && instance.libraryItemId && !this.selectedMediaItems.some((i) => i.id === instance.libraryItemId)) { + const entity = this.entities[index] + const mediaItem = { + id: entity.id, + libraryId: entity.libraryId, + mediaType: entity.mediaType, + hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length) + } + this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: true }) + } } } } diff --git a/client/store/globals.js b/client/store/globals.js index 34e17371d..d2a796cae 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -225,5 +225,12 @@ export const mutations = { } else if (selected && !isAlreadySelected) { state.selectedMediaItems.push(item) } + }, + addBatchMediaItemsSelected(state, items) { + items.forEach((item) => { + if (!state.selectedMediaItems.some((i) => i.id === item.id)) { + state.selectedMediaItems.push(item) + } + }) } } diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index af4528763..3cc4179f2 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -1938,6 +1938,66 @@ class LibraryItemController { }) } + /** + * POST: /api/items/batch/reset-metadata + * Reset metadata for multiple library items + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async batchResetMetadata(req, res) { + if (!req.user.canUpdate) { + Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch reset metadata without permission`) + return res.sendStatus(403) + } + + const { libraryItemIds } = req.body + if (!Array.isArray(libraryItemIds) || !libraryItemIds.length) { + return res.status(400).send('Invalid request') + } + + const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ + id: libraryItemIds + }) + + const results = [] + for (const libraryItem of libraryItems) { + try { + if (global.MetadataPath) { + const metadataPath = Path.join(global.MetadataPath, 'items', libraryItem.id, 'metadata.json') + if (await fs.pathExists(metadataPath)) { + Logger.info(`[LibraryItemController] Removing metadata file at "${metadataPath}"`) + await fs.remove(metadataPath) + } + } + + if (libraryItem.path && !libraryItem.isFile) { + const localMetadataPath = Path.join(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 (libraryItem.media.coverPath) { + libraryItem.media.coverPath = null + await libraryItem.media.save() + } + + // Trigger a scan ensuring we don't rely on cache/timestamps if possible + await LibraryItemScanner.scanLibraryItem(libraryItem.id) + + results.push({ id: libraryItem.id, success: true }) + } catch (error) { + Logger.error(`[LibraryItemController] Batch Reset Metadata: Failed to reset "${libraryItem.media?.title}"`, error) + results.push({ id: libraryItem.id, success: false, error: error.message }) + } + } + + res.json({ results }) + } + /** * * @param {RequestWithUser} req diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index cb312e52e..a8c187aab 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -108,6 +108,7 @@ class ApiRouter { this.router.post('/items/batch/move', LibraryItemController.batchMove.bind(this)) this.router.post('/items/batch/merge', LibraryItemController.batchMerge.bind(this)) this.router.post('/items/batch/consolidate', LibraryItemController.batchConsolidate.bind(this)) + this.router.post('/items/batch/reset-metadata', LibraryItemController.batchResetMetadata.bind(this)) this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this)) this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))