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
+ })
+ })
+ })
+})