From 56eca373041107dd8f2ba9e7f180727700d1bb9e Mon Sep 17 00:00:00 2001 From: Tiberiu Ichim Date: Thu, 12 Feb 2026 19:57:04 +0200 Subject: [PATCH] Allow books to be merged --- artifacts/2026-02-05/local_migration.md | 42 ++-- artifacts/2026-02-11/recursive_libraries.md | 63 ++++++ artifacts/2026-02-12/book-merge.md | 129 ++++++++++++ client/components/app/Appbar.vue | 47 +++++ client/pages/config/libraries.vue | 22 ++- client/strings/en-us.json | 5 + server/controllers/LibraryItemController.js | 183 ++++++++++++++++++ server/routers/ApiRouter.js | 1 + .../LibraryItemController_merge.test.js | 148 ++++++++++++++ 9 files changed, 615 insertions(+), 25 deletions(-) create mode 100644 artifacts/2026-02-11/recursive_libraries.md create mode 100644 artifacts/2026-02-12/book-merge.md create mode 100644 test/server/controllers/LibraryItemController_merge.test.js diff --git a/artifacts/2026-02-05/local_migration.md b/artifacts/2026-02-05/local_migration.md index 398d3d321..e7e0165fa 100644 --- a/artifacts/2026-02-05/local_migration.md +++ b/artifacts/2026-02-05/local_migration.md @@ -17,64 +17,54 @@ The source database was created in a Docker container environment with hardcoded ### 1. libraryFolders Table -**Table**: `libraryFolders` -**Columns with paths**: `path` +**Table**: `libraryFolders` **Columns with paths**: `path` -**Sample data**: -| id | path | libraryId | -|----|------|-----------| -| 9f980819-1371-4c8f-9e7d-a6cbe9ae1ba7 | /audiobooks | a04cbf28-7eb6-4c87-b3e3-421ad8b35923 | -| 43bf8c8d-07b6-4828-848f-8bb1e3dcca04 | /libraries/romance | dad4448d-77c2-481e-9212-1ffcb4272932 | +**Sample data**: | id | path | libraryId | |----|------|-----------| | 9f980819-1371-4c8f-9e7d-a6cbe9ae1ba7 | /audiobooks | a04cbf28-7eb6-4c87-b3e3-421ad8b35923 | | 43bf8c8d-07b6-4828-848f-8bb1e3dcca04 | /libraries/books | dad4448d-77c2-481e-9212-1ffcb4272932 | **Migration strategy**: + - Map each unique library folder path to a corresponding local path - Preserve the folder structure within each library ### 2. libraryItems Table -**Table**: `libraryItems` -**Columns with paths**: `path`, `relPath` +**Table**: `libraryItems` **Columns with paths**: `path`, `relPath` -**Sample data**: -| id | path | relPath | title | -|----|------|---------|-------| -| 6ec745f9-608e-4556-8f78-b36e2682069b | /audiobooks/A Beginner's Guide to Forever.m4b | A Beginner's Guide to Forever.m4b | A Beginner's Guide to Forever | +**Sample data**: | id | path | relPath | title | |----|------|---------|-------| | 6ec745f9-608e-4556-8f78-b36e2682069b | /audiobooks/A Beginner's Guide to Forever.m4b | A Beginner's Guide to Forever.m4b | A Beginner's Guide to Forever | **Migration strategy**: + - The `path` column contains full absolute paths from Docker root - The `relPath` column contains paths relative to library folder (less likely to need migration) - Update `path` to use local library folder mappings ### 3. books Table -**Table**: `books` -**Columns with paths**: `coverPath` +**Table**: `books` **Columns with paths**: `coverPath` -**Sample data**: -| id | coverPath | -|----|-----------| -| 68f4e9ca-c8e9-46a1-b667-7b0a409dd72d | /metadata/items/6ec745f9-608e-4556-8f78-b36e2682069b/cover.jpg | +**Sample data**: | id | coverPath | |----|-----------| | 68f4e9ca-c8e9-46a1-b667-7b0a409dd72d | /metadata/items/6ec745f9-608e-4556-8f78-b36e2682069b/cover.jpg | **Migration strategy**: + - `coverPath` points to `/metadata/items/{libraryItemId}/cover.jpg` - May need remapping if local `metadata` directory differs from Docker ### 4. feeds Table -**Table**: `feeds` -**Columns with paths**: `serverAddress`, `feedURL`, `imageURL`, `siteURL`, `coverPath` +**Table**: `feeds` **Columns with paths**: `serverAddress`, `feedURL`, `imageURL`, `siteURL`, `coverPath` **Migration strategy**: + - `serverAddress`: The Docker container's server URL (e.g., `http://audiobookshelf:8080`) - `feedURL`, `imageURL`, `siteURL`: URLs containing the server address - `coverPath`: Local file path to feed cover images ### 5. settings Table -**Table**: `settings` -**Key with paths**: `server-settings` (JSON value) +**Table**: `settings` **Key with paths**: `server-settings` (JSON value) **Path settings in JSON**: + - `backupPath`: Docker path (e.g., `/metadata/backups`) - Potentially others in nested JSON structure @@ -86,7 +76,7 @@ The source database was created in a Docker container environment with hardcoded # path-mapping.yaml libraries: /audiobooks: /home/user/audiobooks - /libraries/romance: /home/user/libraries/romance + /libraries/books: /home/user/libraries/books metadata: source: /metadata target: /home/user/audiobookshelf/metadata @@ -111,23 +101,27 @@ server: ## Implementation Phases ### Phase 1: Path Discovery + - [ ] Scan all tables for path-like values - [ ] Identify all unique paths requiring migration - [ ] Categorize paths by type (library folders, metadata, URLs) ### Phase 2: Mapping Configuration + - [ ] Create mapping configuration file - [ ] Define library folder path mappings - [ ] Define metadata path mappings - [ ] Define server URL mappings ### Phase 3: Migration Script + - [ ] Implement path update logic for each table - [ ] Implement URL update logic for feeds - [ ] Implement settings path updates - [ ] Add transaction safety with rollback capability ### Phase 4: Validation + - [ ] Run validation checks on migrated database - [ ] Generate migration report - [ ] Test database with local Audiobookshelf instance diff --git a/artifacts/2026-02-11/recursive_libraries.md b/artifacts/2026-02-11/recursive_libraries.md new file mode 100644 index 000000000..9cb1a4aed --- /dev/null +++ b/artifacts/2026-02-11/recursive_libraries.md @@ -0,0 +1,63 @@ +# Recursive Library Structure Fixer Specification + +**Date:** 2026-02-11 +**Status:** Implemented + +## Overview + +This document specifies the behavior of the Python utility (`scripts/reorganize_library.py`) designed to crawl and reorganize deeply nested audiobook library structures into a flat, Audiobookshelf (ABS) compatible format. + +## Problem Statement + +The ABS scanner performs optimally with shallow hierarchies. Deeply nested structures (e.g., `Author / Series / Book / files`) cause metadata misclassification (Author/Series shifting) and inefficiency. Additionally, "Collection" folders often contain single intro files that cause the scanner to swallow all sub-books into one item. + +## Migration Strategy: Top-Level Flattening + +The script reorganizes the library so that every book occupies a single folder directly under the library root. + +### 1. Primary Naming Pattern +The target structure is: +`LibraryRoot / {CleanAuthor} - {BookPathSegments} / {Files}` + +**Refined Naming Logic:** +1. **Author Cleaning**: The first folder segment is treated as the Author. Common suffixes are stripped to avoid clutter: + * `" Collection"`, `" Anthology"`, `" Series"`, `" Books"`, `" Works"`, `" Complete"` +2. **Redundancy Check**: If the rest of the path (the "Book" part) already starts with the Author's name (case-insensitive), the Author prefix is **not** added again. +3. **Deduplication**: Adjacent identical segments in the final name are merged (e.g., `Book - Book` becomes `Book`). + +### 2. Detection Logic (Leaf Node Identification) +A directory is identified as a "Book Folder" if: +1. It contains audio files (`.mp3`, `.m4b`, etc.) AND has **no subdirectories**. +2. It contains audio files AND subdirectories, but **has more than 1 audio file**. + * *Reason*: Prevents "Collection" folders with a single `intro.mp3` from being treated as books, allowing the script to traverse deeper to find the actual books. + * *Exception*: If subdirectories are named `CD 1`, `Disc 1`, etc., it is treated as a book regardless of file count. +3. It contains **only** `CD`/`Disc` subdirectories (even if no audio files are in the root). + +### 3. Transformation Examples + +| Source Path (Relative to Root) | Target Folder Name | Reason | +| :--- | :--- | :--- | +| `Stephen Baxter Collection / Manifold / Origin` | `Stephen Baxter - Manifold - Origin` | "Collection" stripped; "Manifold" preserved. | +| `Abbie Rushton / Unspeakable` | `Abbie Rushton - Unspeakable` | Standard Author - Title. | +| `Dungeon Crawler Carl / Book 1 Dungeon Crawler Carl` | `Dungeon Crawler Carl - Book 1` | Deduplication of "Dungeon Crawler Carl". | +| `Fiction / Author / Book` | `Fiction - Author - Book` | "Fiction" treated as Author context if deeper than 2 levels. | + +## Python Script Interface + +### Location +`scripts/reorganize_library.py` + +### Usage +```bash +python3 scripts/reorganize_library.py /path/to/library [options] +``` + +### Arguments +- `path`: Root directory of the library to scan. +- `--dry-run`: **(Recommended)** Print all planned moves without executing them. +- `--verbose`: Enable debug logging (shows every folder checked and why it was accepted/rejected). + +### Technical Constraints +- **Atomic Moves**: Uses `shutil.move` for safety. +- **Conflict Handling**: Skips the move if a folder with the target name already exists. +- **Cleanup**: Automatically removes empty parent directories after moving their contents. diff --git a/artifacts/2026-02-12/book-merge.md b/artifacts/2026-02-12/book-merge.md new file mode 100644 index 000000000..30abb0725 --- /dev/null +++ b/artifacts/2026-02-12/book-merge.md @@ -0,0 +1,129 @@ +# Specification: Merge Books Feature + +## Implementation Plan + +# Merge Books Feature Implementation Plan + +## Goal Description + +Allow users to select multiple books (e.g., individual mp3 files improperly imported as separate books) and "Merge" them into a single book. This involves moving all files to a single folder and updating the database to reflect a single library item containing all files. + +## Proposed Changes + +### Backend + +#### [NEW] `server/controllers/LibraryItemController.js` + +- Implement `batchMerge(req, res)` method. + - **Validation**: Ensure user has update/delete permissions. Check all items belong to the same library and are books. + - **Primary Item Selection**: Use the first selected item as the "primary" item (the one that will act as the container). + - **Target Folder**: Determine the target folder path. + - If the primary item is already in a suitable folder (e.g. `Author/Title`), use it. + - If the items are in the root or disorganized, create a new folder based on the primary item's metadata (Author/Title). + - **File Operations**: + - Iterate through all _other_ selected items. + - Move their media files (audio, ebook, cover) to the target folder. + - Handle filename collisions (append counter if needed). + - Update `LibraryItem` entries? No, we will rescind the primary item. + - **Database Updates**: + - Delete the _other_ `LibraryItem` records from the database. + - Trigger a rescan of the primary item's folder to pick up the new files and update tracks/chapters. + - Clean up empty source folders of the moved items. + +#### [MODIFY] `server/routers/ApiRouter.js` + +- Add `POST /items/batch/merge` route mapped to `LibraryItemController.batchMerge`. + +### Frontend + +#### [MODIFY] `client/components/app/Appbar.vue` + +- Update `contextMenuItems` computed property. +- Add "Merge" option when: + - User has update/delete permissions. + - Library is a "book" library. + - Multiple items are selected (`selectedMediaItems.length > 1`). +- Implement `batchMerge()` method to call the API. + - Add confirmation dialog explaining what will happen. + +## Verification Plan + +### Manual Verification + +1. **Setup**: + - Add multiple individual mp3 files to the root of a library (or separate folders) so they show up as separate books. + - Ensure they have some metadata (Title/Author) or add it manually. +2. **Execution**: + - Go to the library in the web UI. + - Select the multiple "books" (mp3 files). + - Click the Multi-select "x items selected" bar if not already open (it opens automatically on selection). + - Click the Context Menu (3 dots) or find the "Merge" button (to be added). + - Select "Merge". + - Confirm the dialog. +3. **Result Validation**: + - Verify that the separate books disappear. + - Verify that one single book remains. + - Open the remaining book and check "Files" tab. It should contain all the mp3 files. + - Check the filesystem: Ensure all mp3 files are now in the same folder. + - Check metadata: Ensure the book plays correctly. + +## Walkthrough + +# Merge Books Feature Walkthrough + +I have implemented the "Merge Books" feature, which allows users to combine multiple library items (specifically books) into a single library item. This is particularly useful for fixing issues where individual audio files were imported as separate books. + +## Changes + +### Backend + +- **`server/controllers/LibraryItemController.js`**: Added `batchMerge` method. + - Validates that all items are books and from the same library. + - Identifies a "primary" item (the first one selected). + - Creates a new folder for the book if the primary item is a file in the root. + - Moves all media files from the other selected items into the primary item's folder. + - Deletes the old library items for the moved files. + - Triggers a scan of the primary item to update metadata and tracks. + - Cleans up empty authors and series. +- **`server/routers/ApiRouter.js`**: Added `POST /api/items/batch/merge` route. + +### Frontend + +- **`client/components/app/Appbar.vue`**: Added "Merge" option to the multi-select context menu. + - Enabled only when multiple books are selected. + - Shows a confirmation dialog before proceeding. +- **`client/strings/en-us.json`**: Added localization strings for the new feature. + +## Verification + +### Automated Tests + +I created a new test file `test/server/controllers/LibraryItemController_merge.test.js` to verify the backend logic. + +To run the verification test: + +```bash +npx mocha test/server/controllers/LibraryItemController_merge.test.js --exit +``` + +**Test Results:** + +``` + LibraryItemController Merge + batchMerge + ✔ should merge two file-based items into a new folder + + 1 passing (113ms) +``` + +### Manual Verification Steps + +1. **Identify Split Books**: Find a set of books in your library that should be a single book (e.g., "Chapter 1", "Chapter 2" showing as separate books). +2. **Select Books**: Enable multi-select and click on the books you want to merge. +3. **Click Merge**: In the top URI bar, click the menu button (3 dots) and select **Merge**. +4. **Confirm**: Accept the confirmation dialog ("Are you sure you want to merge..."). +5. **Verify**: + - The separate books should disappear. + - A single book should remain (based on the first selected book). + - Open the book and check the "Files" tab. It should contain all the audio files. + - Play the book to ensure tracks are ordered and playable. diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 543bc6b18..fa47d90ba 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -36,6 +36,12 @@ + + + + + + @@ -195,6 +201,14 @@ export default { text: this.$strings.LabelMoveToLibrary, action: 'move-to-library' }) + + // Merge option - only for books and if multiple selected + if (this.isBookLibrary && this.selectedMediaItems.length > 1) { + options.push({ + text: this.$strings.LabelMerge, + action: 'merge' + }) + } } return options @@ -236,8 +250,41 @@ export default { this.batchDownload() } else if (action === 'move-to-library') { this.batchMoveToLibrary() + } else if (action === 'merge') { + this.batchMerge() } }, + batchMerge() { + const payload = { + message: this.$strings.MessageConfirmBatchMerge, + callback: (confirmed) => { + if (confirmed) { + const libraryItemIds = this.selectedMediaItems.map((i) => i.id) + this.$store.commit('setProcessingBatch', true) + this.$axios + .$post('/api/items/batch/merge', { libraryItemIds }) + .then((data) => { + if (data.success) { + this.$toast.success(this.$strings.ToastBatchMergeSuccess) + } else { + this.$toast.warning(this.$strings.ToastBatchMergePartiallySuccess) + } + this.cancelSelectionMode() + }) + .catch((error) => { + console.error('Batch merge failed', error) + const errorMsg = error.response.data || this.$strings.ToastBatchMergeFailed + this.$toast.error(errorMsg) + }) + .finally(() => { + this.$store.commit('setProcessingBatch', false) + }) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, batchMoveToLibrary() { // Clear any single library item that might be lingering this.$store.commit('setSelectedLibraryItem', null) diff --git a/client/pages/config/libraries.vue b/client/pages/config/libraries.vue index efb9c49c5..0ed35971a 100644 --- a/client/pages/config/libraries.vue +++ b/client/pages/config/libraries.vue @@ -32,12 +32,32 @@ export default { } }, computed: {}, + watch: { + '$route.query.edit': { + handler(val) { + if (val) { + const library = this.$store.state.libraries.libraries.find((lib) => lib.id === val) + if (library) { + this.setShowLibraryModal(library) + } + } + } + } + }, methods: { setShowLibraryModal(selectedLibrary) { this.selectedLibrary = selectedLibrary this.showLibraryModal = true } }, - mounted() {} + mounted() { + const editLibraryId = this.$route.query.edit + if (editLibraryId) { + const library = this.$store.state.libraries.libraries.find((lib) => lib.id === editLibraryId) + if (library) { + this.setShowLibraryModal(library) + } + } + } } diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 54c38a575..d46cedb38 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -460,6 +460,7 @@ "LabelMaxEpisodesToKeepHelp": "Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. This will only delete 1 episode per new download.", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", + "LabelMerge": "Merge", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", @@ -772,6 +773,7 @@ "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook", "MessageChaptersNotFound": "Chapters not found", "MessageCheckingCron": "Checking cron...", + "MessageConfirmBatchMerge": "Are you sure you want to merge the selected books into a single book? The files will be moved to the first selected book's folder, and other books will be deleted.", "MessageConfirmCloseFeed": "Are you sure you want to close this feed?", "MessageConfirmDeleteApiKey": "Are you sure you want to delete API key \"{0}\"?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", @@ -1018,6 +1020,9 @@ "ToastBatchApplyDetailsToItemsSuccess": "Details applied to items", "ToastBatchDeleteFailed": "Batch delete failed", "ToastBatchDeleteSuccess": "Batch delete success", + "ToastBatchMergeFailed": "Failed to merge books", + "ToastBatchMergePartiallySuccess": "Books merged with some errors", + "ToastBatchMergeSuccess": "Books merged successfully", "ToastBatchQuickMatchFailed": "Batch Quick Match failed!", "ToastBatchQuickMatchStarted": "Batch Quick Match of {0} books started!", "ToastBatchUpdateFailed": "Batch update failed", diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 5c4c6c4df..a5b32f3e1 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -1588,6 +1588,189 @@ class LibraryItemController { } } + + /** + * POST: /api/items/batch/merge + * Merge multiple library items into one + * + * @this {import('../routers/ApiRouter')} + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async batchMerge(req, res) { + if (!req.user.canDelete) { + Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch merge items without permission`) + return res.sendStatus(403) + } + + const { libraryItemIds } = req.body + if (!libraryItemIds?.length || !Array.isArray(libraryItemIds) || libraryItemIds.length < 2) { + return res.status(400).send('Invalid request body. Must select at least 2 items.') + } + + const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ + id: libraryItemIds + }) + + if (libraryItems.length !== libraryItemIds.length) { + return res.status(404).send('Some library items not found') + } + + const libraryId = libraryItems[0].libraryId + + // Validate all items are in the same library and are books + const invalidItem = libraryItems.find((li) => li.libraryId !== libraryId || li.mediaType !== 'book') + if (invalidItem) { + return res.status(400).send('All items must be books in the same library') + } + + // Sort items by ID to be deterministic, user selection order is lost in findAllExpandedWhere + // To preserve user selection order, we map libraryItemIds to objects + const orderedLibraryItems = libraryItemIds.map((id) => libraryItems.find((li) => li.id === id)).filter((li) => li) + const primaryItem = orderedLibraryItems[0] + const otherItems = orderedLibraryItems.slice(1) + + const primaryItemPath = primaryItem.path + // If primary item is file, its dir is dirname. If folder, its dir is path. + const primaryItemDir = primaryItem.isFile ? Path.dirname(primaryItemPath) : primaryItemPath + + const library = await Database.libraryModel.findByIdWithFolders(libraryId) + const libraryFolder = library.libraryFolders.find((lf) => primaryItemPath.startsWith(lf.path)) + + if (!libraryFolder) { + Logger.error(`[LibraryItemController] Library folder not found for primary item "${primaryItem.media.title}" path "${primaryItemPath}"`) + return res.status(500).send('Library folder not found for primary item') + } + + let targetDirPath = primaryItemDir + + // If primary item is a single file in the root of the library folder, + // create a new folder for the merged book. + // primaryItemDir check: + // If primaryItem.isFile is true, primaryItemDir is parent dir. + // If primaryItemDir == libraryFolder.path, it means it's in the root of library folder. + const isPrimaryInRoot = primaryItemDir === libraryFolder.path + + if (isPrimaryInRoot) { + // Create a new folder for the merged book + const author = primaryItem.media.authors?.[0]?.name || 'Unknown Author' + const title = primaryItem.media.title || 'Unknown Title' + // Simple sanitization + const folderName = `${author} - ${title}`.replace(/[/\\?%*:|"<>]/g, '').trim() + targetDirPath = Path.join(libraryFolder.path, folderName) + + if (await fs.pathExists(targetDirPath)) { + // Directory already exists, append timestamp to avoid conflict + targetDirPath += ` (${Date.now()})` + } + await fs.ensureDir(targetDirPath) + + // Move primary item file to new folder + const newPrimaryPath = Path.join(targetDirPath, Path.basename(primaryItemPath)) + await fs.move(primaryItemPath, newPrimaryPath) + + // Update primary item path in memory (DB update will happen on scan) + primaryItem.path = newPrimaryPath + primaryItem.relPath = Path.relative(libraryFolder.path, newPrimaryPath) + } + + Logger.info(`[LibraryItemController] Merging ${otherItems.length} items into "${primaryItem.media.title}" at "${targetDirPath}"`) + + const successIds = [] + const failIds = [] + const failedItems = [] + + for (const item of otherItems) { + try { + const itemPath = item.path + if (item.isFile) { + const filename = Path.basename(itemPath) + let destPath = Path.join(targetDirPath, filename) + + // Handle collision + if (await fs.pathExists(destPath)) { + const name = Path.parse(filename).name + const ext = Path.parse(filename).ext + destPath = Path.join(targetDirPath, `${name}_${Date.now()}${ext}`) + } + await fs.move(itemPath, destPath) + } else { + // It's a directory + // Move all files from this directory to target directory + const files = await fs.readdir(itemPath) + for (const file of files) { + const srcFile = Path.join(itemPath, file) + let destFile = Path.join(targetDirPath, file) + + if (await fs.pathExists(destFile)) { + const name = Path.parse(file).name + const ext = Path.parse(file).ext + destFile = Path.join(targetDirPath, `${name}_${Date.now()}${ext}`) + } + + // If it's a directory inside, move recursively? + // Users shouldn't have nested books usually. fs.move works for dirs too. + await fs.move(srcFile, destFile) + } + // Remove the now empty directory + await fs.remove(itemPath) + } + + // Delete the library item from DB + // We pass empty array for mediaItemIds because we moved the files, so we don't want to delete them if they were linked. + // Actually handleDeleteLibraryItem deletes from DB handling relationships. + // But we already moved the files. + // If hard delete was called, it would try to delete files. But we didn't call delete with hard=1 logic here. + // We manually moved files. + // Now we just need to remove the DB entry. + + // However, handleDeleteLibraryItem removes media progress, playlists, etc. + await this.handleDeleteLibraryItem(item.id, [item.media.id]) + successIds.push(item.id) + } catch (error) { + Logger.error(`[LibraryItemController] Failed to merge item ${item.id}`, error) + failIds.push(item.id) + failedItems.push({ id: item.id, error: error.message }) + } + } + + // Rescan the target folder + // If moved to folder, tell scanner + if (isPrimaryInRoot) { + // We changed the structure of primary item + await LibraryItemScanner.scanLibraryItem(primaryItem.id, { + path: targetDirPath, + relPath: Path.relative(libraryFolder.path, targetDirPath), + isFile: false + }) + } else { + // Just rescan content + await LibraryItemScanner.scanLibraryItem(primaryItem.id) + } + + // Check remove empty authors/series for deleted items + // We can collect all author/series IDs from deleted items + const authorIdsToCheck = [] + const seriesIdsToCheck = [] + otherItems.forEach((item) => { + if (successIds.includes(item.id)) { + if (item.media.authors) authorIdsToCheck.push(...item.media.authors.map((a) => a.id)) + if (item.media.series) seriesIdsToCheck.push(...item.media.series.map((s) => s.id)) + } + }) + + if (authorIdsToCheck.length) await this.checkRemoveAuthorsWithNoBooks([...new Set(authorIdsToCheck)]) + if (seriesIdsToCheck.length) await this.checkRemoveEmptySeries([...new Set(seriesIdsToCheck)]) + + res.json({ + success: failIds.length === 0, + successIds, + failIds, + errors: failedItems + }) + } + /** * * @param {RequestWithUser} req diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 08cc3e5fe..14ac67fb9 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -106,6 +106,7 @@ class ApiRouter { this.router.post('/items/batch/quickmatch', LibraryItemController.batchQuickMatch.bind(this)) this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this)) this.router.post('/items/batch/move', LibraryItemController.batchMove.bind(this)) + this.router.post('/items/batch/merge', LibraryItemController.batchMerge.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)) diff --git a/test/server/controllers/LibraryItemController_merge.test.js b/test/server/controllers/LibraryItemController_merge.test.js new file mode 100644 index 000000000..1c0d5d89c --- /dev/null +++ b/test/server/controllers/LibraryItemController_merge.test.js @@ -0,0 +1,148 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') +const fs = require('../../../server/libs/fsExtra') +const Path = require('path') + +const Database = require('../../../server/Database') +const ApiRouter = require('../../../server/routers/ApiRouter') +const LibraryItemController = require('../../../server/controllers/LibraryItemController') +const ApiCacheManager = require('../../../server/managers/ApiCacheManager') +const Auth = require('../../../server/Auth') +const Logger = require('../../../server/Logger') +const LibraryItemScanner = require('../../../server/scanner/LibraryItemScanner') + +describe('LibraryItemController Merge', () => { + /** @type {ApiRouter} */ + let apiRouter + + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + apiRouter = new ApiRouter({ + auth: new Auth(), + apiCacheManager: new ApiCacheManager() + }) + + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'error') + sinon.stub(Logger, 'warn') + + // Mock fs-extra methods + sinon.stub(fs, 'ensureDir').resolves() + sinon.stub(fs, 'move').resolves() + sinon.stub(fs, 'pathExists').resolves(false) + sinon.stub(fs, 'readdir').resolves([]) + sinon.stub(fs, 'remove').resolves() + sinon.stub(fs, 'rmdir').resolves() + + // Mock Scanner + sinon.stub(LibraryItemScanner, 'scanLibraryItem').resolves() + }) + + afterEach(async () => { + sinon.restore() + // Clear all tables + await Database.sequelize.sync({ force: true }) + }) + + describe('batchMerge', () => { + it('should merge two file-based items into a new folder', async () => { + // Setup Library and Folder + const library = await Database.libraryModel.create({ name: 'Book Lib', mediaType: 'book' }) + const libraryFolder = await Database.libraryFolderModel.create({ path: '/books', libraryId: library.id }) + + // Item 1: File in root + const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [] }) + const item1 = await Database.libraryItemModel.create({ + mediaId: book1.id, + mediaType: 'book', + libraryId: library.id, + libraryFolderId: libraryFolder.id, + path: '/books/book1.mp3', + relPath: 'book1.mp3', + isFile: true + }) + // Create Author + const author = await Database.authorModel.create({ name: 'Author 1', libraryId: library.id }) + await Database.bookAuthorModel.create({ bookId: book1.id, authorId: author.id }) + + await item1.save() + + // Item 2: File in root + const book2 = await Database.bookModel.create({ title: 'Book 1 Part 2', audioFiles: [] }) + const item2 = await Database.libraryItemModel.create({ + mediaId: book2.id, + mediaType: 'book', + libraryId: library.id, + libraryFolderId: libraryFolder.id, + path: '/books/book2.mp3', + relPath: 'book2.mp3', + isFile: true + }) + item2.media = book2 + + // Mock user + const user = { canDelete: true, username: 'admin' } + + const req = { + user, + body: { + libraryItemIds: [item1.id, item2.id] + } + } + + const res = { + json: sinon.spy(), + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await LibraryItemController.batchMerge.bind(apiRouter)(req, res) + + // Verify response + if (res.status.called) { + console.log('Error status:', res.status.args[0]) + console.log('Error send:', res.send.args[0]) + } + expect(res.json.calledOnce).to.be.true + const result = res.json.args[0][0] + expect(result.success).to.be.true + expect(result.successIds).to.have.lengthOf(1) // Only item2 is "processed" (deleted), item1 is updated + expect(result.successIds).to.include(item2.id) + + // Verify fs calls + // Should create folder "Author 1 - Book 1" + const expectedFolderPath = Path.join('/books', 'Author 1 - Book 1') + expect(fs.ensureDir.calledWith(expectedFolderPath)).to.be.true + + // Should move item1 + expect(fs.move.calledWith('/books/book1.mp3', Path.join(expectedFolderPath, 'book1.mp3'))).to.be.true + + // Should move item2 + expect(fs.move.calledWith('/books/book2.mp3', Path.join(expectedFolderPath, 'book2.mp3'))).to.be.true + + // item1 should be updated (path change) - NOT checked here because scanLibraryItem is mocked and it handles the update + // const updatedItem1 = await Database.libraryItemModel.findByPk(item1.id) + // expect(updatedItem1.path).to.equal(Path.join(expectedFolderPath, 'book1.mp3')) + + // item2 should be deleted + const updatedItem2 = await Database.libraryItemModel.findByPk(item2.id) + expect(updatedItem2).to.be.null + + // Verify Scanner called + expect(LibraryItemScanner.scanLibraryItem.called).to.be.true + const scanArgs = LibraryItemScanner.scanLibraryItem.firstCall.args + expect(scanArgs[0]).to.equal(item1.id) + expect(scanArgs[1]).to.deep.equal({ + path: expectedFolderPath, + relPath: 'Author 1 - Book 1', + isFile: false + }) + }) + }) +})