feat: Library Import page (Sonarr-style)#398
feat: Library Import page (Sonarr-style)#398tsolo4ever wants to merge 50 commits intoListenarrs:canaryfrom
Conversation
- New route /library-import with full-page import UX - Pinia store (libraryImport.ts) with FIFO lookup queue, scan, per-row search, and batch import - LibraryImportRow.vue: checkbox, folder path, detected metadata, inline match search with debounce - LibraryImportFooter.vue: sticky toolbar with Start Processing / Cancel / Import buttons and rate-limit warning (>100 lookups) - LibraryImportView.vue: root folder selector, scan button, last-scanned timestamp, results table - Uncomment nav link in App.vue; add PhFolderOpen import - Queue processes one item at a time (naturally rate-limit safe, matching Sonarr's approach) - UnmatchedFilesModal.vue unchanged — additive change Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SearchResult has no 'author' string field — use authors[] only - Narrow lookupQueue[0] with non-null assertion to satisfy strict index checks - Use object spread assignment instead of direct index mutation for items record Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Folder names often contain year prefixes and series brackets (e.g. "2021 - Cleave's Edge [Morcster Chef 1]") which produce poor search results. When file tags provide a clean detectedTitle, use that instead for both auto-processing queue searches and the inline search pre-fill. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ASIN from file tags gives an exact match via advancedSearch({ asin }).
Falls back to detectedTitle, then folderName for title search.
Inline search panel pre-fill follows the same priority.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Scans a root folder for audio files not already in the library,
parses metadata from the folder path structure and sidecar files
(desc.txt, reader.txt), and surfaces results for manual review.
Users can add each unmatched book to the library via the existing
AddLibraryModal and optionally link the file via manual-import.
- PathMetadataParser: parses {Author}/{Series}/{Year} - {Title} [{Series} #]/
folder structure plus desc.txt and reader.txt sidecar files
- UnmatchedScanQueueService: channel-based background job queue
- UnmatchedScanBackgroundService: walks root folder, compares against
tracked AudiobookFiles in DB, groups by book folder, pushes
UnmatchedScanComplete via SettingsHub SignalR on completion
- RootFoldersController: POST /{id}/scan-unmatched and
GET /unmatched-results/{jobId} endpoints
- UnmatchedFilesModal: two-phase modal (scan progress → results table
with Add/Ignore per row) launched from root folder card
- RootFoldersSettings: magnifying glass button on each folder card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds ffprobe-based tag extraction to the unmatched scan pipeline. For each book group, reads album_artist (author), composer (narrator), SERIES, PART, date, description, and ASIN from embedded iTunes/Nero atoms. Embedded tags take priority over path-parsed values. ffprobe calls run in parallel (max 4 concurrent) to avoid overwhelming the NAS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rvice Add FakeUnmatchedQueue stub and update all controller instantiations to include the new required constructor parameter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds UnmatchedScanConcurrency setting (default 2, range 1-8) to ApplicationSettings. The unmatched scan reads this at job start via IConfigurationService and passes it to Parallel.ForEachAsync, so users can tune NAS I/O pressure vs. scan speed from General Settings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Track last completed scan job per root folder path in UnmatchedScanQueueService
- Add CompletedAt timestamp to UnmatchedScanJob
- Add TryGetLastJobForPath() to IUnmatchedScanQueueService interface
- New GET /rootfolders/{id}/unmatched endpoint returns cached results for a folder
- Modal now loads saved results on open (no auto-scan)
- Added explicit Scan button — only rescans when clicked
- Shows 'Last scanned X ago' timestamp on cached results
- Shows empty state with instructions when no scan has run yet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Controller constructor now requires ListenArrDbContext (added for GetSavedUnmatched filtering). Tests use an in-memory DB via CreateDb(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ManualImport_MultiFileCollisionTests: pass IRootFolderService mock to updated
ManualImportController constructor
- SearchControllerTests + SearchControllerUnifiedTests: advanced search now returns
{ results: [...], totalResults: N } instead of a bare array; update assertions
to unwrap the results property before checking array length and elements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ok.FilePath Books imported via manual import only have Audiobook.FilePath set — the AudiobookFiles table is empty for them. The scanner was only checking AudiobookFiles, causing already-imported books to appear as unmatched and triggering a 409 on add. Now checks both sources. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UnmatchedFilesModal addAllWithAsin now passes destinationPath so BasePath is set correctly on the created audiobook (fixing silent import failure). GetSavedUnmatched filters cached results against current library state so re-opening the modal no longer shows already-added items. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… wording - UnmatchedScanBackgroundService: files directly in the root folder (flat layout) now each produce their own result entry. Previously all flat files shared the same parent-folder group key → one entry with one representative file's metadata (and fileCount = N). Now each flat file is its own group; bookFolder falls back to the actual parent directory for the import path. - UnmatchedFilesModal: eagerly load applicationSettings + rootFolders on open so the 'Move to:' / 'Copy to:' label and destination dropdown always reflect the user's configured file action and available root folders. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…youts
Replace flat-root special case with a general two-level grouping strategy:
1. Group files by parent directory (folder = one audiobook, as before)
2. Within each directory that has multiple files, sub-group by a normalized
title stem derived from the filename (strip track numbers, Part/CD/Disc
suffixes, year/series brackets, normalize whitespace)
- Same stem or all-empty (numbered parts: 1.m4b, 2.m4b) → one audiobook entry
- Different stems (author folder with multiple books, flat root with
distinct titles) → separate audiobook entries per title
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- matchToMetadata: extract series name/position/asin from AudimetaSeries[]
(advancedSearch returns series as array; AudibleBookMetadata expects string)
This was causing import failures when a matched book had series data
- searchItem: detect 10-char ASIN pattern and pass as { asin } not { title }
so manual search works when user enters or pre-fill contains an ASIN
- LibraryImportRow: display series name correctly in results dropdown
(was showing raw JSON array object)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…oot folder ManualImportController.GenerateManualImportPathAsync was treating any BasePath that differed from settings.OutputPath as a 'custom' destination, applying only the file naming pattern and placing files flat into that folder with no subfolder hierarchy. This broke batch adds from UnmatchedFilesModal when the selected root folder wasn't the same path as OutputPath. Fix: inject IRootFolderService and check whether BasePath matches any configured root folder. If it does, isCustomBasePath = false → full folder+file naming pattern is applied → files are correctly organized into Author/Title/File.m4b. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…or UnmatchedScanConcurrency Register IUnmatchedScanQueueService singleton outside the DisableHostedServices guard so RootFoldersController can be resolved in tests. Add UnmatchedScanBackgroundService to HostedServiceRegistrationExtensions. Add missing EF migration for the UnmatchedScanConcurrency property on ApplicationSettings, which was causing PendingModelChangesWarning to abort migrations at startup in the test host, leaving all tables uncreated and causing 10 test failures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace static 'Move to:' / 'Copy to:' label with dropdown-first layout showing the actual root folder path: '[Move ▼] to: /path/to/folder'. Path truncates with ellipsis and full path on hover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hared folders Files like "The Land (1).m4b", "The Land (2).m4b" in one folder were all reducing to the same stem "the land" and being grouped as one multi-part audiobook. They are actually separate books in a series. Changes to ExtractTitleStem: - Keep plain numeric parens (1), (2) etc. — these distinguish series books - Only strip 4-digit year parens (2020), (2021) - Only strip square bracket content [Series Name], [Chaos Seeds 1] - Add 'pt' to the trailing part-number strip so "pt00" is removed (pt00 = whole book, never increments — safe to strip) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the static path label with a <select> showing all root folders, letting users choose where to import files independently of the scan folder. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…earch
- importSelected: when addToLibrary returns 409 (book already in library),
extract the existing audiobook from the conflict response body and continue
with startManualImport — fixes import failure for Missing books
- buildSearchTitle: extract trailing number from filename (e.g. "The Land (3).m4b")
and append to the search query ("The Land 3") so series book numbers resolve better
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When addToLibrary returns 409 (book already in library), the existing audiobook may have BasePath=null or pointing to the wrong location. Update BasePath to the selected destination folder before calling startManualImport so the naming service generates the correct path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ismatches - pickBestMatch: when detectedAuthor is available, prefer the search result whose author matches over just taking results[0] - LibraryImportRow: show matched author in orange when it doesn't match detectedAuthor, with tooltip showing the detected author Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…thors/narrators Search results return authors without names; fetch full Audimeta data at import time (same pattern as UnmatchedFilesModal). Also fix v-if guards in detail view to hide author/narrator rows when arrays are empty rather than showing blank labels. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- processNext: ASIN results are authoritative, skip pickBestMatch author comparison (simplified API responses lack authors array, causing null) - triggerScan: fix race condition where fast SignalR completion was discarded because jobId was not yet assigned; add 2.5s polling fallback for when SignalR is unavailable or the event is dropped Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Audimeta returns series as AudimetaSeries[] at runtime despite the TS type being string; backend rejects with 400 validation error. Same fix pattern as genres — extract [0].name when it's an array. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ar stale import entries AudioFileService: skip registering a file already linked to another audiobook — stops focused post-import scans from attributing all files in an Author/Series/ folder to whichever audiobook triggered the scan. RootFoldersController: filter GetSavedUnmatched results by File.Exists so moved/imported source files no longer reappear in the import table after a page reload. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Track failed author lookups in authorCoverNotFound Set so image error events don't re-trigger the metadata/author endpoint on every render. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Track failed author lookups in authorCoverNotFound Set so image error events don't re-trigger the metadata/author endpoint on every render. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When /metadata/author finds no result from Audimeta, try Audnexus SearchAuthorsAsync — prefers exact name match, falls back to first result. Same image caching path applies to both sources. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When /metadata/author finds no result from Audimeta, try Audnexus SearchAuthorsAsync — prefers exact name match, falls back to first result. Same image caching path applies to both sources. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Keep selectedMatch/hasSearched/selected per item when Scan Again is triggered — new scan results merge with existing state instead of wiping it. Also adds seriesAsin to SearchResult and AudibleBookMetadata types (was missing, caused TS errors). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Writes the audiobook's ASIN to the file's embedded tags immediately after the file is moved/copied during manual import. Supports M4B/M4A (iTunes dash box), MP3 (TXXX frame), and FLAC/OGG/Opus (Vorbis comment). Tag write failures are logged as warnings and do not block the import. Also adds a db-reset Docker Compose profile for easy test DB resets. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spacer had no height binding so the scroll container only grew to fit the ~20 rendered items. Added totalHeight computed (totalRows * rowHeight) bound to the spacer so the scrollbar reflects the full library size and scroll events fire to expand visibleRange. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Persist selectedMatch/hasSearched/selected per item keyed by fullPath to
listenarr-import-matches-{folderId}. initFromRootFolder now loads from
localStorage as fallback when in-memory state is empty (e.g. page reload).
triggerScan clears localStorage on fresh scan start so stale matches from
a previous scan don't bleed into the new results.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
triggerScan clears localStorage at start, but _populateFromItems (which merges prior matches back in-memory) never re-wrote them. Reload after a re-scan would find empty localStorage and lose all 175 matches. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…/add Backend SearchResult.Isbn is List<string> but frontend type is string?. Sending isbn: "9781039417731" caused a 400 validation error. Wrap it in an array the same way normalizeMetadataForApi already does for metadata.isbn. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brings in library-optimizations and all other upstream changes. Resolved one conflict in Program.cs (disableHostedServices logic: take upstream's env-var-aware version, keep our IUnmatchedScanQueueService singleton registration outside the guard). Restored IAudnexusService injection in MetadataController that was dropped during auto-merge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fe/src/stores/libraryImport.ts
Outdated
| language: result.language, | ||
| runtime: result.runtime ?? (result.lengthMinutes ? result.lengthMinutes * 60 : undefined), | ||
| imageUrl: result.imageUrl, | ||
| genres: result.genres, |
There was a problem hiding this comment.
sorry is there any other duplicates? its a lot of code to go through
Replaces two identical inline genre-flattening expressions with a shared
normalizeGenres() function. Handles both string[] and {asin, name, type}[]
shapes returned by Audimeta, ensuring /library/add always receives string[].
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract inline search into LibraryImportSearchModal (modal pattern, auto-searches on open, supports title and ASIN detection) - Button colors now follow project conventions: Start Processing and Scan → btn-primary (blue), Cancel → btn-secondary (grey) - Remove per-component .btn overrides, rely on global buttons.css - Unify select heights with --control-height (40px) - Mobile: footer stacks vertically, Format column hidden at ≤640px Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
therobbiedavis
left a comment
There was a problem hiding this comment.
Hey there, thanks for taking up the mantle on this massive undertaking! I've reviewed this and I think it's a great start and about 85% there. My concerns surround primarily the UX, especially for mobile, and the result.genres not getting flattened causing the import to fail. The others where I give actionables need to be addressed too, but until those primary concerns are addressed this isn't functional.
…vice Completed and failed jobs are now purged after 1 hour on the next EnqueueAsync call, preventing unbounded memory growth from repeated large scans and keeping stale path data from accumulating indefinitely. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rom compose CollectAudioFiles now rejects symlinks and junctions during directory traversal, preventing the scan from escaping the configured root folder into unrelated host paths. Also removes an accidentally committed db-reset dev utility from docker-compose.yml. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ssNext stack depth - ManualImportController: validate item.FullPath is within a configured root folder before File.Move/Copy to prevent path traversal - CollectAudioFiles: use separator-terminated root in StartsWith check to prevent false match on sibling directories (e.g. /audio vs /audio-extra) - processNext: replace tail-recursive async calls with a while loop to avoid 2000-deep call chains on large libraries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ries on batch import - PathMetadataParser.Parse: use separator-terminated root in StartsWith check (same fix as CollectAudioFiles) to prevent false match on sibling directories and subsequent garbage relative-path computation - ManualImportController: fetch root folders once before the item loop instead of once per item, eliminating N redundant DB queries per batch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@tsolo4ever Sorry just noticed this behavior inconsistency, maybe you can help explain. My test audiobook "The Hobbit" is part of the "Lord of the Rings" series. |
…name) The 'album' embedded tag is commonly set to the series name by audiobook rippers, not the individual book title. buildSearchTitle and the search modal initial query now prefer the filename stem when it differs from the folder name (e.g. 'The Hobbit.m4b' in a 'Lord of the Rings' folder correctly searches 'The Hobbit', not the album/series tag). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
oops almost all of mine use the ASIN didnt really thing to test that its good now sorry |
Restores the title + author search capability from the old inline panel. The author input is pre-filled with detectedAuthor and passed to advancedSearch when present; ASIN searches remain title-only as before. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
How are you searching via existing ASIN from the file? |
|
@tsolo4ever Mind if I make some updates to the UI/UX? |


Closes #182
Closes #281
Summary
/library-import— a Sonarr-style interface for importing unmatched audio files into the libraryWhat it does
UnmatchedScanBackgroundService, shows last-scanned timestamp and unmatched countaddToLibrary+startManualImportfor each selected row, removes imported rows on successImplementation notes
libraryImport.ts) manages scan state, lookup queue, match state, and selectionprocessNext()) — no backend job neededdetectedAsin(exact match) →detectedTitle(from file tags) →folderName(fallback)LibraryImportRow.vue— per-row component with inline search panelLibraryImportFooter.vue— sticky footer componentTest plan
/library-import🤖 Generated with Claude Code