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..a3c6b28 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,10 +628,10 @@ public async Task GetAll() return Ok(Array.Empty()); } - 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, @@ -646,18 +662,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 +674,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 +705,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..9072aca 100644 --- a/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs +++ b/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs @@ -8,11 +8,10 @@ namespace Listenarr.Api.Tests public class AudiobookStatusEvaluatorTests { [Fact] - public void ComputeStatus_ReturnsNoFile_WhenWanted() + public void ComputeStatus_ReturnsNoFile_WhenHasNoFiles() { 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()); + } } }