"Skip Parts and Sets" never skips split volumes - #73
Open
JordanFromIT wants to merge 1 commit into
Open
Conversation
The parts/sets filter only looked at SeriesBookLink.Position. Those rows are often sparse, or hold a tidied-up position, while the book itself still carries what the metadata provider returned - "3, Part 1 of 2", "2A", "2 Part B". Consult Book.SeriesPosition too, so a record whose position is not a number is recognised as one slice of a work rather than the work itself. Fractional positions such as 0.5 and 11.5 still parse as numbers, so legitimate novellas are unaffected, and books already on disk stay exempt via localItems.
JordanFromIT
added a commit
to JordanFromIT/chaptarr
that referenced
this pull request
Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Skip Parts and Setsdecides whether a record is a slice of a work by looking only at the position on itsSeriesBookLinkrows. Those rows are frequently sparse, and where they do exist they often hold a tidied-up number, while the book itself still carries the position the metadata provider actually returned —3, Part 1 of 2,2A,4 חלק א'. So the option leaves split volumes in the library with the setting switched on, and because they sit next to the real book under the same author with a near-identical title, grabs import onto them instead of the book you monitored. This makes the filter consultBook.SeriesPositionas well: a position that is populated but does not parse as a number means the record is one slice of a work, not the work. Oneifblock and one extracted helper, plus eight regression tests.There is no existing issue for this — happy to file one if you'd rather have it tracked separately.
A concrete example. Harry Potter has a Japanese split-volume children's edition whose halves are separate records: position
2Afor the first half of Chamber of Secrets,3, Part 1 of 2for Prisoner of Azkaban. WithSkip Parts and Setsenabled they all survive the filter. In my library a grab for Chamber of Secrets then imported onto the2Arecord rather than the monitored book — the files land in a correctly named folder, so nothing looks wrong on disk, but the monitored book stays empty and the series metadata written into the files is the split-volume series. That happened three times in one day before I traced it here.What already works, and what doesn't. The plumbing is all present; only the last row is missing.
skipPartsAndSetsexposed in the metadata profile UI and APIMetadataProfile, read byFilterBooksIsPartOrSetrecognises a non-numericSeriesBookLink.PositionIsPartOrSetrecognises a non-numericBook.SeriesPositionTechnical detail
MetadataProfileService.FilterBookscalls:seriesLinksis built frominput.Series → LinkItems, so a book reachesIsPartOrSetwithnulllinks whenever the refresh did not populate series link items for it. The first check inIsPartOrSetis then skipped entirely:Note the second clause as well: a single link that parses to a number is enough to clear the whole book, so a book with links
["3", "3, Part 1 of 2"]is treated as a normal entry. The remaining checks areTitle1 / Title2splitting andPartOrSetRegexagainstbook.Title, and a split volume whose title is just the ordinary book title passes both.Book.SeriesPositionis the denormalised per-book position and survives all of this. It is already populated by the metadata refresh and already used elsewhere for display, so this adds a read, not a new source of data.The change:
IsNumericSeriesPositionis a one-line extraction of thedouble.TryParsealready used on the link path, so both checks stay in step.Deliberate choices worth flagging for review
SeriesBookLink.Positionof3and aBook.SeriesPositionof3, Part 1 of 2; this treats it as a part. That is the judgement call most worth challenging here — the opposite reading is that the link rows are the curated value and should override. I went with the book because the link rows are what is missing or flattened in every case I could reproduce, and because the filter is opt-in and exempts anything already on disk, so a false positive costs a record you did not want rather than a file you did.double.TryParsecall rather than pinningCultureInfo.InvariantCulture. Keeping both checks identical seemed better than having the new path disagree with the old one. Happy to switch both to invariant if you'd rather — see the known gaps below.true, so ordering is cosmetic; this way the existing comment stays attached to the code it describes.Blast radius.
IsPartOrSethas exactly one caller, the predicate above. The whole path is behindSkipPartsAndSets, which defaults tofalseon both seeded profiles in001_chaptarr_complete_schema, so nothing changes for anyone who has not enabled it.FilterByPredicateonly removes items absent fromlocalItems, so books that already have files, or were added manually, are never dropped by this.Measured on my own library, 587 books, 333 of which carry a series position:
Every newly filtered value is a genuine split-part or split-edition designation. Fractional positions are the case I was most worried about breaking — legitimate novellas like The Hedge Knight at
0.5and the4.1/4.2entries all parse as numbers and are untouched. There is a regression test pinning that.Known gaps I deliberately did not fix
double.TryParsewithout an explicit culture means3,5parses on ade-DEhost and3.5may be read as35. That is pre-existing on the link path and I did not want to change existing behaviour inside a bug fix, but it is a real latent issue and I'll fold an invariant-culture change into this PR if you want it.SeriesBookLink.Positionbeing flattened or missing whereBook.SeriesPositionis populated is a refresh-side problem and a much larger change.While tracing this I also noticed that a single numeric link position clears a book even when its other link positions are non-numeric (the
!seriesLinks.Any(...)clause quoted above). It looks intentional — "at least one real slot" — so I left it alone rather than fold an unrelated behaviour change in here. Flagging it so it isn't mistaken for something this PR should have covered.Database Migration
NO. No schema change of any kind.
Book.SeriesPositionandMetadataProfile.SkipPartsAndSetsare both existing columns created in001_chaptarr_complete_schema— this only reads a field that was already populated. No new migration file, no change toVersionInfo, and the branch runs against an existing database with no upgrade step.How was this tested?
Native
dotneton Linux (.NET 10.0.111), SQLite backend.Tests first, watched failing before the fix existed. New fixture
MetadataProfileServicePartsAndSetsFixture, 8 tests, driving the realMetadataProfileService.FilterBooksend to end rather than the private method:should_skip_book_whose_series_position_is_a_split_volume3, Part 1 of 2is filteredshould_skip_book_whose_series_position_is_a_lettered_part2Ais filteredshould_skip_split_volume_even_when_the_series_link_position_is_clean3should_keep_book_whose_series_position_is_a_whole_number2survivesshould_keep_book_whose_series_position_is_fractional0.5survives — the novella guardshould_keep_book_that_has_no_series_positionnullsurvivesshould_keep_split_volume_when_parts_and_sets_filter_is_disabledshould_keep_split_volume_that_is_already_on_disklocalItemsexemption holdsThe five "keep" tests passed before the fix as well — they exist to pin behaviour I did not want to change, and the first run proves they were green beforehand rather than being written to match new code.
Counts, base commit
develop(5713d83): 2838 → 2846 (+8), 0 failing, both Debug and Release.The tests do catch the bug. Reverting only the production hunk and leaving the fixture untouched:
CI commands from
.github/workflows/build.yml, run locally:package.jsonparsesversion_guard.py sync0.9.929version_guard.py monotonic --compare-ref origin/develop0.9.929vs0.9.929version_guard.py commit-hygiene --compare-ref origin/developdotnet build src/Chaptarr.NoTests.sln --configuration Releasedotnet test src/Chaptarr.Core.Test/Chaptarr.Core.Test.csproj --configuration Releasedotnet publish(Console + Update)No frontend files are touched, so the yarn steps aren't applicable.
Against real data. The blast-radius figures above come from querying the
Bookstable of a live instance (587 books, two authors' worth of series data from Hardcover and Goodreads) and evaluating each distinctSeriesPositionwith the same whole-string numeric semantics asdouble.TryParse.What I did not test. I have not run this against the PostgreSQL backend — the change is a pure in-memory predicate with no SQL, but I can't claim I exercised it there. I also have not tested under a non-English locale, which is exactly where the culture gap above would show up. And I have not let a full refresh run to completion on a library with these split volumes present to watch the records disappear; the evidence here is unit-level plus the query above. Happy to run any of those if you'd like them before merging.
Screenshots (UI changes only)
None — no UI changes. The setting and its help text are unchanged; only its behaviour is.