mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-04 06:59:41 +00:00
Allow books to be merged
This commit is contained in:
parent
fc97b10f58
commit
56eca37304
9 changed files with 615 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
63
artifacts/2026-02-11/recursive_libraries.md
Normal file
63
artifacts/2026-02-11/recursive_libraries.md
Normal file
|
|
@ -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.
|
||||
129
artifacts/2026-02-12/book-merge.md
Normal file
129
artifacts/2026-02-12/book-merge.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -36,6 +36,12 @@
|
|||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="userIsAdminOrUp && currentLibrary" :to="`/config/libraries?edit=${currentLibrary.id}`" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<ui-tooltip :text="$strings.HeaderUpdateLibrary" direction="bottom" class="flex items-center">
|
||||
<span class="material-symbols text-2xl" aria-label="Edit Library" role="button"></span>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
||||
<span class="material-symbols text-2xl" aria-label="System Settings" role="button"></span>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
148
test/server/controllers/LibraryItemController_merge.test.js
Normal file
148
test/server/controllers/LibraryItemController_merge.test.js
Normal file
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue