Skip to content
Merged
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
21 changes: 21 additions & 0 deletions fe/src/__tests__/audiobookStatus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
10 changes: 10 additions & 0 deletions fe/src/utils/audiobookStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
66 changes: 37 additions & 29 deletions listenarr.api/Controllers/LibraryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -612,10 +628,10 @@ public async Task<IActionResult> GetAll()
return Ok(Array.Empty<LibraryAudiobookListItemDto>());
}

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,
Expand Down Expand Up @@ -646,18 +662,9 @@ public async Task<IActionResult> 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();
Expand All @@ -667,8 +674,10 @@ public async Task<IActionResult> 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)
{
Expand Down Expand Up @@ -696,17 +705,16 @@ public async Task<IActionResult> 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);
Expand Down
1 change: 1 addition & 0 deletions listenarr.api/Models/LibraryAudiobookListItemDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
21 changes: 15 additions & 6 deletions listenarr.api/Services/AudiobookStatusEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ public static class AudiobookStatusEvaluator

public static string ComputeStatus(
bool isDownloading,
bool wanted,
bool hasAnyFile,
string? audiobookQuality,
QualityProfile? qualityProfile,
Expand All @@ -35,11 +34,6 @@ public static string ComputeStatus(
return Downloading;
}

if (wanted)
{
return NoFile;
}

if (!hasAnyFile)
{
return NoFile;
Expand Down Expand Up @@ -76,6 +70,11 @@ public static string ComputeStatus(

if (candidateFiles.Count == 0)
{
if (files == null || files.Count == 0)
{
return QualityMatch;
}

return QualityMismatch;
}

Expand Down Expand Up @@ -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";
Expand Down
42 changes: 37 additions & 5 deletions tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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<string> { "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<string> { "wv" },
Qualities = new List<QualityDefinition>
{
new() { Quality = "lossless", Priority = 0 }
}
};
var files = new List<AudiobookFileStatusInfo>
{
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<string> preferredFormats)
{
return new QualityProfile
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,46 @@ public async Task GetAll_TreatsDbFileRecordAsNotWanted_EvenIfPathIsMissing()

Assert.False(wanted);
}

[Fact]
public async Task GetAll_TreatsLegacyFilePathAsNotWanted_WhenNoFileRowsExist()
{
var options = new DbContextOptionsBuilder<ListenArrDbContext>()
.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<IAudiobookRepository>(),
Mock.Of<IImageCacheService>(),
NullLogger<LibraryController>.Instance,
db,
provider.GetRequiredService<IServiceScopeFactory>(),
Mock.Of<IFileNamingService>());

var actionResult = await controller.GetAll();
var ok = Assert.IsType<OkObjectResult>(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());
}
}
}
Loading