mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 05:29:41 +00:00
feat: add library-wide consolidation status update tool and improve consolidation robustness
This commit is contained in:
parent
2c77f1fc5a
commit
c2693e2460
8 changed files with 118 additions and 11 deletions
|
|
@ -48,6 +48,17 @@ The "Consolidate" feature allows users to organize their book library by renamin
|
|||
- `handleMoveLibraryItem` assumes `itemFolderName` is `Path.basename(libraryItem.path)`. It does NOT rename the folder name itself.
|
||||
- So we need a custom logic or a modified helper that supports renaming.
|
||||
- **Response**: JSON object indicating success and the updated item.
|
||||
- **Robustness Improvements**:
|
||||
- Enhanced `handleMoveLibraryItem` to detect if the source and destination paths are identical.
|
||||
- Skips file move operation if the paths match, preventing "Destination already exists" Errors.
|
||||
- Ensures `Watcher` ignore directories are correctly managed even when paths are identical.
|
||||
|
||||
## Batch Consolidation
|
||||
|
||||
- **Endpoint**: `POST /api/items/batch/consolidate`
|
||||
- **Controller**: `LibraryItemController.batchConsolidate`
|
||||
- **Logic**: Iterates through selected item IDs and calls the consolidation logic for each.
|
||||
- **Response**: Summary of successful and failed consolidation operations.
|
||||
|
||||
## Artifacts
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,19 @@ Add a visual indicator (badge) to the book thumbnail card in listings to identif
|
|||
|
||||
### Backend (Server)
|
||||
- **Model**: `LibraryItem` (`server/models/LibraryItem.js`)
|
||||
- **Logic**: Added `checkIsNotConsolidated()` which:
|
||||
1. Checks if the item is a book folder.
|
||||
2. Sanitizes the `Author - Title` name using `sanitizeFilename`.
|
||||
- **Logic**: Enhanced `checkIsNotConsolidated()` which:
|
||||
1. Checks if the item is a book folder (not a single file).
|
||||
2. Sanitizes the `Author - Title` name using `LibraryItem.getConsolidatedFolderName(author, title)`.
|
||||
3. Compares the sanitized name with the folder's name (`Path.basename(this.path)`).
|
||||
- **API**: The flag `isNotConsolidated` is included in the JSON response for library items.
|
||||
4. **Subfolder Check**: Verifies the item is located at the root of the library folder. If it's in a subfolder (e.g., `Author/Title`), it's considered "Not Consolidated" even if the folder name is correct.
|
||||
|
||||
### Library-wide Status Update Tool
|
||||
A tool was added to the Library Settings to allow manual re-evaluation of the consolidation status for all items.
|
||||
|
||||
- **Frontend**: Added "Update Consolidation Status" button in Library Settings -> Tools tab.
|
||||
- **Backend Controller**: `LibraryController.updateConsolidationStatus`
|
||||
- **API**: `POST /api/libraries/:id/update-consolidation`
|
||||
- **Behavior**: Iterates through all items in the library, runs `checkIsNotConsolidated()`, and updates the database flag if it has changed. This is useful if the folder structure was manually altered on disk outside of the application.
|
||||
|
||||
### Frontend (Client)
|
||||
- **Component**: `LazyBookCard` (`client/components/cards/LazyBookCard.vue`)
|
||||
|
|
@ -32,4 +40,4 @@ Add a visual indicator (badge) to the book thumbnail card in listings to identif
|
|||
- **UI (Badge)**: Badge added next to the book title when `isNotConsolidated` is true.
|
||||
- **UI (Button)**: "Consolidate" button added to the primary action row (after Edit and Mark as Finished).
|
||||
- **Behavior**: The "Consolidate" button is disabled if the book is already consolidated.
|
||||
- **Cleanup**: The "Consolidate" option has been removed from the context menu on this page.
|
||||
- **Robustness**: Modified `handleMoveLibraryItem` to correctly identify when a book is already at its target path, avoiding redundant file operations and preventing "destination already exists" errors.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,18 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<p class="text-lg">{{ $strings.LabelUpdateConsolidationStatus }}</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelUpdateConsolidationStatusHelp }}</p>
|
||||
</div>
|
||||
<div class="grow" />
|
||||
<div>
|
||||
<ui-btn @click.stop="updateConsolidationStatusClick">{{ $strings.ButtonUpdate }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -158,6 +170,34 @@ export default {
|
|||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
},
|
||||
updateConsolidationStatusClick() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmUpdateConsolidationStatus,
|
||||
persistent: true,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.updateConsolidationStatus()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
updateConsolidationStatus() {
|
||||
this.$emit('update:processing', true)
|
||||
this.$axios
|
||||
.$post(`/api/libraries/${this.libraryId}/update-consolidation`)
|
||||
.then((data) => {
|
||||
this.$toast.success(this.$getString('ToastUpdateConsolidationStatusSuccess', [data.updated]))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update consolidation status', error)
|
||||
this.$toast.error(this.$strings.ToastUpdateConsolidationStatusFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@
|
|||
"ButtonStartMetadataEmbed": "Start Metadata Embed",
|
||||
"ButtonStats": "Stats",
|
||||
"ButtonSubmit": "Submit",
|
||||
"ButtonUpdate": "Update",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUnlinkOpenId": "Unlink OpenID",
|
||||
"ButtonUpload": "Upload",
|
||||
|
|
@ -286,6 +287,8 @@
|
|||
"LabelChaptersFound": "chapters found",
|
||||
"LabelCleanupAuthors": "Cleanup authors",
|
||||
"LabelCleanupAuthorsHelp": "Remove authors that have no books in this library.",
|
||||
"LabelUpdateConsolidationStatus": "Update Consolidation Status",
|
||||
"LabelUpdateConsolidationStatusHelp": "Checks all items in this library and updates their consolidation status. This is useful if you have manually moved folders on disk.",
|
||||
"LabelClickForMoreInfo": "Click for more info",
|
||||
"LabelClickToUseCurrentValue": "Click to use current value",
|
||||
"LabelClosePlayer": "Close player",
|
||||
|
|
@ -806,6 +809,7 @@
|
|||
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
||||
"MessageConfirmRemoveItemsWithIssues": "Are you sure you want to remove all items with issues?",
|
||||
"MessageConfirmCleanupAuthors": "Are you sure you want to remove all authors with no books in this library?",
|
||||
"MessageConfirmUpdateConsolidationStatus": "Are you sure you want to update the consolidation status for all items in this library? This will re-calculate the 'Not Consolidated' badge for every book.",
|
||||
"MessageConfirmRemoveEpisodeNote": "Note: This does not delete the audio file unless toggling \"Hard delete file\"",
|
||||
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
||||
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||
|
|
@ -1188,6 +1192,8 @@
|
|||
"ToastUserPasswordMismatch": "Passwords do not match",
|
||||
"ToastUserPasswordMustChange": "New password cannot match old password",
|
||||
"ToastUserRootRequireName": "Must enter a root username",
|
||||
"ToastUpdateConsolidationStatusSuccess": "Successfully updated consolidation status for {0} items.",
|
||||
"ToastUpdateConsolidationStatusFailed": "Failed to update consolidation status.",
|
||||
"TooltipAddChapters": "Add chapter(s)",
|
||||
"TooltipAddOneSecond": "Add 1 second",
|
||||
"TooltipAdjustChapterStart": "Click to adjust start time",
|
||||
|
|
|
|||
|
|
@ -1374,6 +1374,39 @@ class LibraryController {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/libraries/:id/update-consolidation
|
||||
* Update isNotConsolidated flag for all items in library
|
||||
*
|
||||
* @param {LibraryControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateConsolidationStatus(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to update consolidation status`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const items = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
libraryId: req.library.id
|
||||
})
|
||||
|
||||
let updatedCount = 0
|
||||
for (const item of items) {
|
||||
const isNotConsolidated = item.checkIsNotConsolidated()
|
||||
if (item.isNotConsolidated !== isNotConsolidated) {
|
||||
item.isNotConsolidated = isNotConsolidated
|
||||
await item.save()
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`[LibraryController] Updated consolidation status for ${updatedCount} items in library "${req.library.name}"`)
|
||||
res.json({
|
||||
updated: updatedCount
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/libraries/:id/podcast-titles
|
||||
*
|
||||
|
|
|
|||
|
|
@ -58,19 +58,22 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n
|
|||
|
||||
// Check if destination already exists
|
||||
const destinationExists = await fs.pathExists(newPath)
|
||||
if (destinationExists) {
|
||||
const isSamePath = oldPath === newPath
|
||||
if (destinationExists && !isSamePath) {
|
||||
throw new Error(`Destination already exists: ${newPath}`)
|
||||
}
|
||||
|
||||
try {
|
||||
Watcher.addIgnoreDir(oldPath)
|
||||
Watcher.addIgnoreDir(newPath)
|
||||
if (!isSamePath) Watcher.addIgnoreDir(newPath)
|
||||
|
||||
const oldRelPath = libraryItem.relPath
|
||||
|
||||
// Move files on disk
|
||||
Logger.info(`[LibraryItemController] Moving item "${libraryItem.media.title}" from "${oldPath}" to "${newPath}"`)
|
||||
await fs.move(oldPath, newPath)
|
||||
if (!isSamePath) {
|
||||
Logger.info(`[LibraryItemController] Moving item "${libraryItem.media.title}" from "${oldPath}" to "${newPath}"`)
|
||||
await fs.move(oldPath, newPath)
|
||||
}
|
||||
|
||||
// Update database within a transaction
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
|
|
@ -276,7 +279,7 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n
|
|||
throw error
|
||||
} finally {
|
||||
Watcher.removeIgnoreDir(oldPath)
|
||||
Watcher.removeIgnoreDir(newPath)
|
||||
if (typeof isSamePath !== 'undefined' && !isSamePath) Watcher.removeIgnoreDir(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -927,7 +927,12 @@ class LibraryItem extends Model {
|
|||
const title = this.title || 'Unknown Title'
|
||||
const folderName = LibraryItem.getConsolidatedFolderName(author, title)
|
||||
const currentFolderName = Path.basename(this.path.replace(/[\/\\]$/, ''))
|
||||
return currentFolderName !== folderName
|
||||
if (currentFolderName !== folderName) return true
|
||||
|
||||
// Check if it is in a subfolder
|
||||
const relPathPOSIX = (this.relPath || '').replace(/\\/g, '/')
|
||||
const cleanRelPath = relPathPOSIX.replace(/\/$/, '')
|
||||
return cleanRelPath !== currentFolderName
|
||||
}
|
||||
|
||||
static getConsolidatedFolderName(author, title) {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ class ApiRouter {
|
|||
this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
|
||||
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||
this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this))
|
||||
this.router.post('/libraries/:id/update-consolidation', LibraryController.middleware.bind(this), LibraryController.updateConsolidationStatus.bind(this))
|
||||
this.router.get('/libraries/:id/podcast-titles', LibraryController.middleware.bind(this), LibraryController.getPodcastTitles.bind(this))
|
||||
this.router.get('/libraries/:id/download', LibraryController.middleware.bind(this), LibraryController.downloadMultiple.bind(this))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue