Skip to content

Series order depends on the server's locale: a position of "1.5" sorts after book 10 #795

Description

@m4bard

GetTypedBooksBySeriesAsinAsync orders a series by parsing the Audible position into a decimal:

private static decimal ParseSeriesPosition(string? rawPosition)
{
return decimal.TryParse(rawPosition, out var parsed) ? parsed : decimal.MaxValue;
}

private static decimal ParseSeriesPosition(string? rawPosition)
{
    return decimal.TryParse(rawPosition, out var parsed) ? parsed : decimal.MaxValue;
}

feeding the sort at line 165:

.OrderBy(item => ParseSeriesPosition(item.Position))

Position comes from Audible's sequence or sort field, which is a string and always uses . as the decimal separator. The parse takes no IFormatProvider, so it runs under whatever culture the process happens to have.

Who this affects

Worth stating up front, because it is narrower than it first looks. I ran a probe inside mcr.microsoft.com/dotnet/aspnet:10.0, the base image the Dockerfile uses:

environment CurrentCulture ParseSeriesPosition("1.5")
default, as the Dockerfile leaves it invariant (LANG unset) 1.5, correct
-e LANG=de_DE.UTF-8 de-DE 15

So a stock container is fine. It needs a real culture to reach the process, which happens when LANG or LC_ALL is set, and on a desktop install that takes its locale from the OS. I have not tested the Windows or macOS builds, so I am not claiming anything about those.

What it does when it fires

Calling the real method on unmodified canary, over ["1", "1.5", "2", "10", "1-4"]:

position en-US de-DE fr-FR
1 1 1 1
1.5 1.5 15 MaxValue
2 2 2 2
10 10 10 10
1-4 MaxValue MaxValue MaxValue
en-US:  1, 1.5, 2, 10, 1-4      (correct)
de-DE:  1, 2, 10, 1.5, 1-4      ('.' is the group separator, so 1.5 parses as 15)
fr-FR:  1, 2, 10, 1.5, 1-4      ('.' parses as nothing, so 1.5 falls to MaxValue)

A novella at 1.5 lands after book 10 on a German server and after every numbered book on a French one, and the series is returned in that order.

Fix

decimal.TryParse(rawPosition, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed)

The change is one line. I have it working locally with tests over all four cultures.

Prior art

The convention across the *arr projects is to parse machine-format values invariantly at the call site and take display language from an explicit user setting rather than from CurrentCulture. A few that are directly comparable:

  • Sonarr parses fractional episode numbers, the same shape of data, invariantly: Parser.cs:1268
  • Readarr does the same in RssParser.cs:388 and in the shared helper TryParseExtensions.cs:29
  • Sonarr fixed this exact bug in bd601332 (2014), "Framerate in mediainfo is now parsed culture invariant". The diff is a bare Decimal.TryParse gaining NumberStyles and CultureInfo.InvariantCulture, which is the same change proposed above.

Worth being accurate about how it is upheld: there is no analyzer enforcing it (CA1305 is set to suggestion in those repos) and no written policy. It is held up by call-site habit and by tests parameterized over several cultures.

One thing worth deciding rather than assuming

Every position that fails to parse collapses to decimal.MaxValue, so 1-4 and any other non-numeric value sort as equal and their order among themselves falls out of the input order. Invariant parsing does not change that, and 1-4 is a real Audible position for an omnibus rather than bad data.

That is the only thing that alters the shape of the fix. If non-numeric positions should simply stay last, the PR is the parse change and nothing else. If you want them ordered among themselves, that is a second decision and I would rather you made it than guess at it.

Say which and I will open the PR against canary.

Same root cause as #764, different code path: that one is about a position reaching the filename, this one about the order a series is listed in. They share no code, so I have kept them separate.

Environment: canary at 4555ad2, .NET 10, Linux.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions