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(); + })); + } + } +}