Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion listenarr.api/Features/Library/LibraryQueryWorkflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* (at your option) any later version.
*/
using Microsoft.AspNetCore.Mvc;
using Listenarr.Domain.Common;

namespace Listenarr.Api.Features.Library;

Expand Down Expand Up @@ -94,7 +95,7 @@ public async Task<IActionResult> 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,
Expand Down
4 changes: 3 additions & 1 deletion listenarr.application/Mapping/AudiobookDtoFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

using Listenarr.Domain.Common;

namespace Listenarr.Application.Mapping
{
public static class AudiobookDtoFactory
Expand All @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions listenarr.domain/Common/AudiobookFileOrdering.cs
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

namespace Listenarr.Domain.Common
{
/// <summary>
/// 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 <see cref="Audiobook.Files"/> for the frontend should pass it
/// through <see cref="InNaturalOrder"/> so the user sees a stable,
/// human-natural sequence.
///
/// Currently used by:
/// - <c>LibraryController.GetAudiobook</c> (the FE audiobook-detail endpoint)
/// - <c>AudiobookDtoFactory.BuildFromEntity</c> (scan SignalR broadcasts, move ops)
/// </summary>
public static class AudiobookFileOrdering
{
/// <summary>
/// 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 <c>files[0]</c> as a preview source
/// still picks something playable. Returns an empty enumerable for null
/// input — callers can chain <c>.Select(...).ToArray()</c> safely.
/// </summary>
public static IEnumerable<AudiobookFile> InNaturalOrder(IEnumerable<AudiobookFile>? source)
{
if (source == null) return Array.Empty<AudiobookFile>();

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)..];
}
}
}
61 changes: 61 additions & 0 deletions listenarr.domain/Common/NaturalSort.cs
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
using System.Text.RegularExpressions;

namespace Listenarr.Domain.Common
{
/// <summary>
/// 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.
/// </summary>
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);

/// <summary>
/// Build a comparison key for <paramref name="value"/> 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.
/// </summary>
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();
}));
}
}
}