From 4e7e7c151370033861617a5299a8671db9b804cf Mon Sep 17 00:00:00 2001 From: Kevin Heneveld <1192102+kevinheneveld@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:43:28 -0800 Subject: [PATCH] fix(library): sort audiobook files by natural filename order EF returns audiobook files in undefined (row-insertion/scan) order, so multi-part books rendered jumbled on the detail page ("Part 116, 100, 049, 065, 095, 016..." instead of ascending order). Add a shared NaturalSort key builder (treats embedded digit runs as numeric so "Part 2" sorts before "Part 10") and an AudiobookFileOrdering.InNaturalOrder helper, then apply it to the two API projections that feed the frontend file list: the audiobook detail-page endpoint and the DTO builder used by scan/move broadcasts. --- .../Features/Library/LibraryQueryWorkflow.cs | 3 +- .../Mapping/AudiobookDtoFactory.cs | 4 +- .../Common/AudiobookFileOrdering.cs | 66 +++++++++++++++++++ listenarr.domain/Common/NaturalSort.cs | 61 +++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 listenarr.domain/Common/AudiobookFileOrdering.cs create mode 100644 listenarr.domain/Common/NaturalSort.cs diff --git a/listenarr.api/Features/Library/LibraryQueryWorkflow.cs b/listenarr.api/Features/Library/LibraryQueryWorkflow.cs index 51899ec77..7e94c8f6d 100644 --- a/listenarr.api/Features/Library/LibraryQueryWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryQueryWorkflow.cs @@ -8,6 +8,7 @@ * (at your option) any later version. */ using Microsoft.AspNetCore.Mvc; +using Listenarr.Domain.Common; namespace Listenarr.Api.Features.Library; @@ -94,7 +95,7 @@ public async Task GetFilesDebugAsync(int id) => }) .ToList(), tags = audiobook.Tags, - files = audiobook.Files?.Select(file => new + files = AudiobookFileOrdering.InNaturalOrder(audiobook.Files).Select(file => new { id = file.Id, path = file.Path, diff --git a/listenarr.application/Mapping/AudiobookDtoFactory.cs b/listenarr.application/Mapping/AudiobookDtoFactory.cs index 47d55850e..2911b18de 100644 --- a/listenarr.application/Mapping/AudiobookDtoFactory.cs +++ b/listenarr.application/Mapping/AudiobookDtoFactory.cs @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +using Listenarr.Domain.Common; + namespace Listenarr.Application.Mapping { public static class AudiobookDtoFactory @@ -24,7 +26,7 @@ public static AudiobookDto BuildFromEntity(Audiobook audiobook) { if (audiobook == null) return null!; - var files = audiobook.Files?.Select(f => new AudiobookFileDto + var files = AudiobookFileOrdering.InNaturalOrder(audiobook.Files).Select(f => new AudiobookFileDto { Id = f.Id, Path = f.Path, diff --git a/listenarr.domain/Common/AudiobookFileOrdering.cs b/listenarr.domain/Common/AudiobookFileOrdering.cs new file mode 100644 index 000000000..02452ec49 --- /dev/null +++ b/listenarr.domain/Common/AudiobookFileOrdering.cs @@ -0,0 +1,66 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Domain.Common +{ + /// + /// Canonical ordering for the file list attached to an audiobook. + /// + /// EF returns audiobook files in essentially undefined (row-insertion / scan) + /// order, which surfaces in the UI as a jumbled file list — "Disc 08, 01, 06, + /// 10, 07, 09, 11, 12, 03..." for a 14-disc rip. Every API response that + /// projects for the frontend should pass it + /// through so the user sees a stable, + /// human-natural sequence. + /// + /// Currently used by: + /// - LibraryController.GetAudiobook (the FE audiobook-detail endpoint) + /// - AudiobookDtoFactory.BuildFromEntity (scan SignalR broadcasts, move ops) + /// + public static class AudiobookFileOrdering + { + /// + /// Return the supplied files sorted by filename using a natural-sort key + /// (so "Disc 02" precedes "Disc 10"). Files without a path sink to the + /// end so any caller defaulting to files[0] as a preview source + /// still picks something playable. Returns an empty enumerable for null + /// input — callers can chain .Select(...).ToArray() safely. + /// + public static IEnumerable InNaturalOrder(IEnumerable? source) + { + if (source == null) return Array.Empty(); + + return source + .OrderBy(f => string.IsNullOrEmpty(f.Path) ? 1 : 0) + .ThenBy(f => NaturalSort.ToKey(GetFileNameForSort(f.Path)), StringComparer.Ordinal) + .ThenBy(f => f.Path ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ThenBy(f => f.Id); + } + + // Use just the filename for natural-sort purposes — directory separators in + // the full path would otherwise dominate the key and group files by folder + // instead of by name. Handles both forward and back slashes so Windows-rooted + // paths from the importer sort the same as Unix paths from the scanner. + private static string GetFileNameForSort(string? path) + { + if (string.IsNullOrEmpty(path)) return string.Empty; + var lastSlash = path.LastIndexOfAny(new[] { '/', '\\' }); + return lastSlash < 0 ? path : path[(lastSlash + 1)..]; + } + } +} diff --git a/listenarr.domain/Common/NaturalSort.cs b/listenarr.domain/Common/NaturalSort.cs new file mode 100644 index 000000000..d7e4636c1 --- /dev/null +++ b/listenarr.domain/Common/NaturalSort.cs @@ -0,0 +1,61 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.RegularExpressions; + +namespace Listenarr.Domain.Common +{ + /// + /// Natural-sort helpers that treat embedded digit runs as numeric values so that + /// strings like "Disc 2" sort before "Disc 10" (rather than the lexicographic + /// "Disc 10" before "Disc 2" you would get from ordinary string comparison). + /// + /// Used wherever a user-visible list of files, chapters, or numbered items needs + /// to come out in the order a human would expect. + /// + public static class NaturalSort + { + // Splits a string into alternating runs of digits and non-digits so the key + // builder can pad numeric runs to a fixed width before concatenation. + private static readonly Regex NumericChunkPattern = new(@"\d+|\D+", RegexOptions.Compiled); + + /// + /// Build a comparison key for such that ordinary + /// ordinal string comparison on the returned key produces a natural sort. + /// + /// Numeric runs are zero-padded to 12 digits (large enough for any realistic + /// chapter / disc / track number) and non-numeric runs are upper-cased for + /// case-insensitive ordering. + /// + public static string ToKey(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return string.Concat( + NumericChunkPattern.Matches(value).Select(match => + { + var chunk = match.Value; + return int.TryParse(chunk, out var numeric) + ? numeric.ToString("D12") + : chunk.ToUpperInvariant(); + })); + } + } +}