Skip to content

feat: Library Import page (Sonarr-style)#398

Open
tsolo4ever wants to merge 50 commits intoListenarrs:canaryfrom
tsolo4ever:feature/library-import-page
Open

feat: Library Import page (Sonarr-style)#398
tsolo4ever wants to merge 50 commits intoListenarrs:canaryfrom
tsolo4ever:feature/library-import-page

Conversation

@tsolo4ever
Copy link

@tsolo4ever tsolo4ever commented Mar 6, 2026

Closes #182
Closes #281

Summary

  • Adds a full-page Library Import view at /library-import — a Sonarr-style interface for importing unmatched audio files into the library
  • Sidebar nav link added (Library Import, folder-open icon)
  • Manual entry fallback: books with no metadata match can be added with just a title and author
  • Pure frontend addition — all required backend endpoints already existed

What it does

  • Root folder selector + Scan button — triggers UnmatchedScanBackgroundService, shows last-scanned timestamp and unmatched count
  • Results table with per-row inline search:
    • Auto-match state: searching spinner / green matched / yellow no-match / grey unsearched
    • Magnifying glass button opens an inline search panel with debounced live search (400ms), results list with cover thumbs
    • Click a result to apply the match; × button to clear it
    • Manual entry — when search returns no usable results, a "Add manually" option lets you enter title + author directly and still import the file
  • Sticky footer with:
    • Move / Hardlink-Copy mode selector (synced from application settings)
    • "Start Processing" → FIFO queue searches all unmatched items one at a time (naturally rate-limit safe)
    • Live progress counter + Cancel button
    • Orange rate-limit warning badge after >100 API lookups
    • "Import X Books" primary button — calls addToLibrary + startManualImport for each selected row, removes imported rows on success

Implementation notes

  • New Pinia store (libraryImport.ts) manages scan state, lookup queue, match state, and selection
  • FIFO queue pattern (async recursive processNext()) — no backend job needed
  • Auto-search priority: detectedAsin (exact match) → detectedTitle (from file tags) → folderName (fallback)
  • LibraryImportRow.vue — per-row component with inline search panel
  • LibraryImportFooter.vue — sticky footer component

Test plan

  • Sidebar shows "Library Import" nav link → navigates to /library-import
  • Root folder dropdown → select folder → cached scan results load
  • "Scan" button → spinner → table populates with unmatched items
  • "Start Processing" → rows auto-search one at a time; ASIN-tagged files get exact matches
  • Click match search icon → inline search → select result → row turns green
  • No match found → "Add manually" → enter title/author → row ready to import
  • Checkbox selection → "Import X Books" enables
  • Import → books added, files moved, rows removed from table
  • CI build passes

🤖 Generated with Claude Code

tsolo4ever and others added 30 commits March 6, 2026 06:16
- 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>
tsolo4ever and others added 11 commits March 7, 2026 11:00
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>
@tsolo4ever tsolo4ever marked this pull request as ready for review March 9, 2026 03:21
language: result.language,
runtime: result.runtime ?? (result.lengthMinutes ? result.lengthMinutes * 60 : undefined),
imageUrl: result.imageUrl,
genres: result.genres,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry is there any other duplicates? its a lot of code to go through

tsolo4ever and others added 3 commits March 10, 2026 08:36
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>
Copy link
Collaborator

@therobbiedavis therobbiedavis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

tsolo4ever and others added 4 commits March 10, 2026 09:13
…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>
@therobbiedavis
Copy link
Collaborator

@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.
image
But when I search, it searches "Lord of the Rings" (the series) instead of "The Hobbit" the title.
image

…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>
@tsolo4ever
Copy link
Author

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>
@therobbiedavis
Copy link
Collaborator

oops almost all of mine use the ASIN didnt really thing to test that its good now sorry

How are you searching via existing ASIN from the file?

@therobbiedavis
Copy link
Collaborator

@tsolo4ever Mind if I make some updates to the UI/UX?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Ability to manually create / add books [Feature Request] Scanning for existing audiobooks

2 participants