From e285a323fe25962df05784b2b314852d45cc45d6 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 21:21:18 -0400 Subject: [PATCH 1/2] Treat WavPack as lossless; refactor wanted flag Recognize additional lossless codecs (alac, aiff, ape, dsd, wv, wav) in both frontend and backend quality detection and add tests for WavPack. Refactor library wanted-flag computation to prefer DB file rows while honoring the legacy FilePath during upgrade: introduce ComputeWantedFlag overload, avoid touching the filesystem on list endpoints, and expose the transitional FilePath on the DTO with a comment. Consolidate active download statuses into a shared static array and adjust queries to use it. Update AudiobookStatusEvaluator ComputeStatus signature/logic to use hasAnyFile (legacy summary + tracked files) and to treat absence of tracked files but presence of a legacy summary as a quality-match; update tests accordingly. --- fe/src/__tests__/audiobookStatus.spec.ts | 21 ++++++ fe/src/utils/audiobookStatus.ts | 10 +++ .../Controllers/LibraryController.cs | 64 +++++++++++-------- .../Models/LibraryAudiobookListItemDto.cs | 1 + .../Services/AudiobookStatusEvaluator.cs | 21 ++++-- .../AudiobookStatusEvaluatorTests.cs | 40 ++++++++++-- ...aryController_WantedFlagRegressionTests.cs | 41 ++++++++++++ 7 files changed, 161 insertions(+), 37 deletions(-) diff --git a/fe/src/__tests__/audiobookStatus.spec.ts b/fe/src/__tests__/audiobookStatus.spec.ts index 93dab41..bb11b69 100644 --- a/fe/src/__tests__/audiobookStatus.spec.ts +++ b/fe/src/__tests__/audiobookStatus.spec.ts @@ -66,4 +66,25 @@ describe('computeAudiobookStatus', () => { expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match') }) + + it('treats WavPack files as lossless', () => { + const audiobook = { + id: 5, + title: 'Lossless Book', + qualityProfileId: 10, + files: [{ id: 102, format: 'wv', container: 'wv' }], + } as Audiobook + + const profiles: QualityProfile[] = [ + { + id: 10, + name: 'Lossless', + cutoffQuality: 'lossless', + preferredFormats: ['wv'], + qualities: [{ quality: 'lossless', allowed: true, priority: 0 }], + }, + ] + + expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match') + }) }) diff --git a/fe/src/utils/audiobookStatus.ts b/fe/src/utils/audiobookStatus.ts index a9ca114..c73ef05 100644 --- a/fe/src/utils/audiobookStatus.ts +++ b/fe/src/utils/audiobookStatus.ts @@ -129,7 +129,17 @@ function deriveQualityLabel( if ( container.includes('flac') || codec.includes('flac') || + container.includes('alac') || codec.includes('alac') || + container.includes('aiff') || + codec.includes('aiff') || + container.includes('ape') || + codec.includes('ape') || + container.includes('dsd') || + codec.includes('dsd') || + container.includes('wv') || + codec.includes('wv') || + container.includes('wav') || codec.includes('wav') ) { return 'lossless' diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index bb64f3d..6b556e1 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -49,6 +49,14 @@ public class LibraryController : ControllerBase private const int MetadataRescanMaxRequestsPerWindow = 5; private const int MetadataRescanMaxAsinLookupAttempts = 8; private const int MetadataRescanMaxIsbnConversionAttempts = 5; + private static readonly DownloadStatus[] ActiveLibraryDownloadStatuses = + { + DownloadStatus.Queued, + DownloadStatus.Downloading, + DownloadStatus.Paused, + DownloadStatus.Processing, + DownloadStatus.ImportPending + }; private static string? ToStringOrFirst(object? value) { @@ -102,15 +110,23 @@ public LibraryController( private static bool ComputeWantedFlag(Audiobook audiobook) { - if (!audiobook.Monitored) + var files = audiobook.Files; + var hasTrackedFiles = files != null && files.Count > 0; + return ComputeWantedFlag(audiobook.Monitored, hasTrackedFiles, audiobook.FilePath); + } + + private static bool ComputeWantedFlag(bool monitored, bool hasTrackedFiles, string? legacyFilePath) + { + if (!monitored) { return false; } // The library list endpoint should not hit the filesystem for every book. - // Treat existing DB file records as the source of truth for wanted status. - var files = audiobook.Files; - return files == null || files.Count == 0; + // Use AudiobookFiles as the primary source of truth, but honor the legacy + // primary FilePath during the upgrade window so existing installs do not + // suddenly flip back to Wanted before file rows are backfilled. + return !hasTrackedFiles && string.IsNullOrWhiteSpace(legacyFilePath); } private static string ResolvePathWithOptionalBase(string? basePath, string candidatePath) @@ -612,6 +628,8 @@ public async Task GetAll() return Ok(Array.Empty()); } + // Keep this as a handful of set-based queries instead of a wide join so the + // list endpoint stays lean and avoids cartesian duplication across files and downloads. var audiobookIds = audiobooks.Select(a => a.Id).ToArray(); var fileSummaries = await _dbContext.AudiobookFiles .AsNoTracking() @@ -646,18 +664,9 @@ public async Task GetAll() var qualityProfilesById = qualityProfiles.ToDictionary(q => q.Id); - var activeDownloadStatuses = new[] - { - DownloadStatus.Queued, - DownloadStatus.Downloading, - DownloadStatus.Paused, - DownloadStatus.Processing, - DownloadStatus.ImportPending - }; - var activeDownloadAudiobookIds = await _dbContext.Downloads .AsNoTracking() - .Where(d => d.AudiobookId.HasValue && activeDownloadStatuses.Contains(d.Status)) + .Where(d => d.AudiobookId.HasValue && ActiveLibraryDownloadStatuses.Contains(d.Status)) .Select(d => d.AudiobookId!.Value) .Distinct() .ToListAsync(); @@ -667,8 +676,10 @@ public async Task GetAll() var dto = audiobooks.Select(a => { filesByAudiobookId.TryGetValue(a.Id, out var files); - var hasFiles = files != null && files.Count > 0; - var wanted = a.Monitored && !hasFiles; + var hasTrackedFiles = files != null && files.Count > 0; + var hasLegacyFileSummary = !string.IsNullOrWhiteSpace(a.FilePath); + var hasAnyFile = hasTrackedFiles || hasLegacyFileSummary; + var wanted = ComputeWantedFlag(a.Monitored, hasTrackedFiles, a.FilePath); QualityProfile? qualityProfile = null; if (a.QualityProfileId.HasValue) { @@ -696,17 +707,16 @@ public async Task GetAll() FileSize = a.FileSize, FileCount = files?.Count ?? 0, Quality = a.Quality, - QualityProfileId = a.QualityProfileId, - AuthorAsins = a.AuthorAsins?.ToArray(), - Wanted = wanted, - Status = AudiobookStatusEvaluator.ComputeStatus( - activeDownloadAudiobookIdSet.Contains(a.Id), - wanted, - hasFiles, - a.Quality, - qualityProfile, - files) - }; + QualityProfileId = a.QualityProfileId, + AuthorAsins = a.AuthorAsins?.ToArray(), + Wanted = wanted, + Status = AudiobookStatusEvaluator.ComputeStatus( + activeDownloadAudiobookIdSet.Contains(a.Id), + hasAnyFile, + a.Quality, + qualityProfile, + files) + }; }).ToList(); return Ok(dto); diff --git a/listenarr.api/Models/LibraryAudiobookListItemDto.cs b/listenarr.api/Models/LibraryAudiobookListItemDto.cs index 064e5e8..680124d 100644 --- a/listenarr.api/Models/LibraryAudiobookListItemDto.cs +++ b/listenarr.api/Models/LibraryAudiobookListItemDto.cs @@ -19,6 +19,7 @@ public class LibraryAudiobookListItemDto public int? Runtime { get; set; } public string? ImageUrl { get; set; } public bool Monitored { get; set; } + // Transitional legacy primary file summary retained for filters and upgrade compatibility. public string? FilePath { get; set; } public long? FileSize { get; set; } public int FileCount { get; set; } diff --git a/listenarr.api/Services/AudiobookStatusEvaluator.cs b/listenarr.api/Services/AudiobookStatusEvaluator.cs index cd1deb2..eceeb26 100644 --- a/listenarr.api/Services/AudiobookStatusEvaluator.cs +++ b/listenarr.api/Services/AudiobookStatusEvaluator.cs @@ -24,7 +24,6 @@ public static class AudiobookStatusEvaluator public static string ComputeStatus( bool isDownloading, - bool wanted, bool hasAnyFile, string? audiobookQuality, QualityProfile? qualityProfile, @@ -35,11 +34,6 @@ public static string ComputeStatus( return Downloading; } - if (wanted) - { - return NoFile; - } - if (!hasAnyFile) { return NoFile; @@ -76,6 +70,11 @@ public static string ComputeStatus( if (candidateFiles.Count == 0) { + if (files == null || files.Count == 0) + { + return QualityMatch; + } + return QualityMismatch; } @@ -157,7 +156,17 @@ private static string DeriveQualityLabel(AudiobookFileStatusInfo? file, string? var codec = Normalize(file?.Codec); if (container.Contains("flac", StringComparison.Ordinal) || codec.Contains("flac", StringComparison.Ordinal) + || container.Contains("alac", StringComparison.Ordinal) || codec.Contains("alac", StringComparison.Ordinal) + || container.Contains("aiff", StringComparison.Ordinal) + || codec.Contains("aiff", StringComparison.Ordinal) + || container.Contains("ape", StringComparison.Ordinal) + || codec.Contains("ape", StringComparison.Ordinal) + || container.Contains("dsd", StringComparison.Ordinal) + || codec.Contains("dsd", StringComparison.Ordinal) + || container.Contains("wv", StringComparison.Ordinal) + || codec.Contains("wv", StringComparison.Ordinal) + || container.Contains("wav", StringComparison.Ordinal) || codec.Contains("wav", StringComparison.Ordinal)) { return "lossless"; diff --git a/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs b/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs index ea73a24..3506c88 100644 --- a/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs +++ b/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs @@ -12,7 +12,6 @@ public void ComputeStatus_ReturnsNoFile_WhenWanted() { var status = AudiobookStatusEvaluator.ComputeStatus( isDownloading: false, - wanted: true, hasAnyFile: false, audiobookQuality: null, qualityProfile: null, @@ -30,7 +29,7 @@ public void ComputeStatus_ReturnsQualityMismatch_WhenNoFilesMatchPreferredFormat new() { Format = "mp3", Bitrate = 320000 } }; - var status = AudiobookStatusEvaluator.ComputeStatus(false, false, true, null, profile, files); + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); Assert.Equal(AudiobookStatusEvaluator.QualityMismatch, status); } @@ -44,7 +43,7 @@ public void ComputeStatus_ReturnsQualityMatch_WhenDerivedQualityMeetsCutoffBound new() { Format = "m4b", Bitrate = 256000 } }; - var status = AudiobookStatusEvaluator.ComputeStatus(false, false, true, null, profile, files); + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); } @@ -58,11 +57,44 @@ public void ComputeStatus_ReturnsQualityMismatch_WhenDerivedQualityIsBelowCutoff new() { Format = "m4b", Bitrate = 192000 } }; - var status = AudiobookStatusEvaluator.ComputeStatus(false, false, true, null, profile, files); + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); Assert.Equal(AudiobookStatusEvaluator.QualityMismatch, status); } + [Fact] + public void ComputeStatus_ReturnsQualityMatch_WhenOnlyLegacyFileSummaryExists() + { + var profile = CreateProfile(cutoffQuality: "256kbps", preferredFormats: new List { "m4b" }); + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files: null); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + + [Fact] + public void ComputeStatus_TreatsWavPackAsLossless() + { + var profile = new QualityProfile + { + Name = "Lossless Profile", + CutoffQuality = "lossless", + PreferredFormats = new List { "wv" }, + Qualities = new List + { + new() { Quality = "lossless", Priority = 0 } + } + }; + var files = new List + { + new() { Format = "wv", Container = "wv" } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + private static QualityProfile CreateProfile(string cutoffQuality, List preferredFormats) { return new QualityProfile diff --git a/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs b/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs index 4f54d4e..7ef4528 100644 --- a/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs +++ b/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs @@ -64,5 +64,46 @@ public async Task GetAll_TreatsDbFileRecordAsNotWanted_EvenIfPathIsMissing() Assert.False(wanted); } + + [Fact] + public async Task GetAll_TreatsLegacyFilePathAsNotWanted_WhenNoFileRowsExist() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + using var db = new ListenArrDbContext(options); + + var book = new Audiobook + { + Title = "Legacy FilePath Book", + Monitored = true, + FilePath = @"C:\legacy\book.m4b", + FileSize = 2048 + }; + db.Audiobooks.Add(book); + await db.SaveChangesAsync(); + + using var provider = new ServiceCollection().BuildServiceProvider(); + var controller = new LibraryController( + Mock.Of(), + Mock.Of(), + NullLogger.Instance, + db, + provider.GetRequiredService(), + Mock.Of()); + + var actionResult = await controller.GetAll(); + var ok = Assert.IsType(actionResult); + + var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + using var doc = JsonDocument.Parse(json); + var item = doc.RootElement + .EnumerateArray() + .Single(element => element.GetProperty("id").GetInt32() == book.Id); + + Assert.False(item.GetProperty("wanted").GetBoolean()); + Assert.Equal("quality-match", item.GetProperty("status").GetString()); + } } } From 07bfdaa9c29fbc4b19c7259ac3dc3c7cc1f0c16e Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 21:35:42 -0400 Subject: [PATCH 2/2] Query file summaries without ID list; rename test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop expanding an in-memory audiobook ID array into the DB query — fetch AudiobookFiles summaries directly since the endpoint already loads the audiobook table, avoiding large IN() expansion and potential performance issues. Also rename the unit test ComputeStatus_ReturnsNoFile_WhenWanted to ComputeStatus_ReturnsNoFile_WhenHasNoFiles to better reflect the tested condition. --- listenarr.api/Controllers/LibraryController.cs | 6 ++---- tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index 6b556e1..a3c6b28 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -628,12 +628,10 @@ public async Task GetAll() return Ok(Array.Empty()); } - // Keep this as a handful of set-based queries instead of a wide join so the - // list endpoint stays lean and avoids cartesian duplication across files and downloads. - var audiobookIds = audiobooks.Select(a => a.Id).ToArray(); + // Because this endpoint already loads the entire audiobook table, fetch file + // summaries directly instead of expanding a large in-memory ID list into SQL. var fileSummaries = await _dbContext.AudiobookFiles .AsNoTracking() - .Where(f => audiobookIds.Contains(f.AudiobookId)) .Select(f => new AudiobookFileStatusInfo { AudiobookId = f.AudiobookId, diff --git a/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs b/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs index 3506c88..9072aca 100644 --- a/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs +++ b/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs @@ -8,7 +8,7 @@ namespace Listenarr.Api.Tests public class AudiobookStatusEvaluatorTests { [Fact] - public void ComputeStatus_ReturnsNoFile_WhenWanted() + public void ComputeStatus_ReturnsNoFile_WhenHasNoFiles() { var status = AudiobookStatusEvaluator.ComputeStatus( isDownloading: false,