Skip to content

Parse book folders using the configured naming pattern - #856

Open
krejko wants to merge 1 commit into
Listenarrs:canaryfrom
nexalapp:feat/parse-folders-with-configured-pattern
Open

Parse book folders using the configured naming pattern#856
krejko wants to merge 1 commit into
Listenarrs:canaryfrom
nexalapp:feat/parse-folders-with-configured-pattern

Conversation

@krejko

@krejko krejko commented Aug 20, 2026

Copy link
Copy Markdown

Summary

Folder rendering is configurable via FolderNamingPattern, but folder parsing is hardcoded:

// "2010 - The Way of Kings [The Stormlight Archive 1]"
private static readonly Regex BookFolderPattern = new(
    @"^(\d{4})\s+-\s+(.+?)(?:\s+\[(.+?)\s+([\d.]+)\])?$",);

These disagree out of the box. The default pattern is {Author}/{Series}/{Title}, which renders Brandon Sanderson/Stormlight Archive/The Way of Kings — and the parser requires a leading four-digit year, so none of it matches:

'The Way of Kings'      -> NO MATCH
'Stormlight Archive'    -> NO MATCH
'Some Title'            -> NO MATCH

A library organised by Listenarr's own default settings cannot be read back. Any user whose pattern isn't {Year} - {Title} [{Series} {Part}] gets no path-derived series, title or year — scanning silently falls back to embedded tags alone, and books with no series tags simply have no series.

Approach

Derive the matcher from the configured pattern, so parsing is the inverse of rendering.

The derivation mirrors the renderer's elision rules: a bracket group containing only tokens disappears when every token is empty, and renders partially when only some are. So [{Series} {SeriesNumber}] renders [Radicalized] for an unnumbered series — meaning each token inside an elidable group must be independently optional when reading.

Two safeguards:

  • Marker requirement. With every token optional, a bare name satisfies the pattern structurally and an author directory would be claimed as a book. A match is only accepted when it carries a non-title marker (series/year/narrator/…). Author/Series/book.m4b correctly parses to nothing.
  • Fallback. An unparseable pattern yields no matcher rather than throwing; that case and any non-matching folder fall through to the existing convention. Current layouts are unaffected.

Changes

Added

  • NamingPatternFolderMatcher — builds and caches a regex from FolderNamingPattern.
  • PathMetadataParser_ConfiguredPatternTests.

Changed

  • PathMetadataParser accepts an optional pattern and prefers it, falling back to the built-in convention.
  • The unmatched scan passes appSettings.FolderNamingPattern (already loaded ~125 lines above the call site).

Testing

21/21 pass, including the pre-existing PathMetadataParser tests.

Verified end-to-end against a real library with {Author}/[{Series} {SeriesNumber}] {Title} {{Narrator}} ({Year}). Before, series came only from embedded tags — every book without them had none. After:

Folder Before After
[Chronicles of Narnia 0] … Chronicles of Narnia / 0
[Known Space 00.0] … Known Space / 00.0
[Enderverse 07.5][Ender's Saga 1.1] … Enderverse / 07.5
[Radicalized] Radicalized (2019) Radicalized / (no number)
Alastair Reynolds (author dir) correctly rejected

Covers zero-padding, decimals, plain 0, unnumbered series, standalone books, and folders with no year.

Notes

Two tolerances go slightly beyond a literal inversion, both for real-world folder noise:

  1. Extra leading bracket groups[Enderverse 07.5][Ender's Saga 1.1] Title (a book in two series). First bracket wins.
  2. Trailing tags… (1991) [abridged], which otherwise swallows the year into the title.

I've kept them because both occur in practice and neither can be expressed in a pattern, but they are judgement calls — happy to drop either and let those folders fall back if you'd prefer a strict inversion.

The regex is cached per pattern string and compiled with a 1s timeout.

🤖 Generated with Claude Code

Folder rendering is configurable through FolderNamingPattern, but folder parsing
is hardcoded to a single convention:

    BookFolderPattern = ^(\d{4})\s+-\s+(.+?)(?:\s+\[(.+?)\s+([\d.]+)\])?$

The two disagree out of the box. The default pattern is
"{Author}/{Series}/{Title}", which renders "Brandon Sanderson/Stormlight
Archive/The Way of Kings" - and none of those segments match the parser, which
requires a leading four-digit year. A library organised by Listenarr's own
default settings cannot be read back, and any user whose pattern is not
"{Year} - {Title} [{Series} {Part}]" gets no path-derived series, title or year
at all.

Derive the folder matcher from the configured pattern instead, so parsing is the
inverse of rendering. The derivation mirrors the renderer's elision rules: a
bracket group containing only tokens disappears when all of them are empty, and
renders partially when only some are, so each token inside such a group is
independently optional when reading.

Two safeguards:

- Every token being optional means a bare directory name satisfies the pattern
  structurally, so an author folder would be claimed as a book. A match is only
  accepted when it carries a non-title marker (series, year, narrator, ...).
- An unparseable pattern returns no matcher rather than throwing, and both that
  case and a non-matching folder fall back to the built-in convention, so
  existing layouts keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant