Skip to content

AudiobookStatusEvaluator.DeriveQualityLabel produces labels that never match QualityProfile.Qualities keys → all books report quality-mismatch #549

@timk75

Description

@timk75

Summary

Audiobooks always show quality-mismatch even when the file matches the profile's cutoff quality, because AudiobookStatusEvaluator.DeriveQualityLabel returns labels (e.g. "320kbps", "lossless") that never appear as keys in QualityProfile.Qualities (e.g. "MP3 320kbps", "FLAC").

Repro (canary 0.2.73, commit 6fe911a0)

  1. Create a QP via POST /api/v1/qualityprofile with a quality { "quality": "MP3 320kbps", ... } and cutoffQuality: "MP3 320kbps".
  2. Import an audiobook whose only file is a 320 kbps MP3.
  3. Trigger a library scan. Expected: status: "quality-match". Actual: status: "quality-mismatch".

Same symptom for FLAC files against a QP with quality "FLAC": derived label is "lossless", profile key is "flac" → no hit.

Root cause

listenarr.api/Services/AudiobookStatusEvaluator.cs lines 105–138 build qualityPriority keyed on Normalize(quality.Quality) — i.e. the lowercased profile-quality string such as "mp3 320kbps" or "flac".

DeriveQualityLabel (lines 141–192) produces:

  • "320kbps", "256kbps", "192kbps", "<rounded>kbps" for lossy files (no codec prefix), and
  • "lossless" for FLAC/ALAC/etc (no codec name).

qualityPriority.TryGetValue therefore always misses, returns int.MaxValue, the cutoff comparison fails, and every file falls through to return QualityMismatch; on line 138.

Impact

  • Every audiobook in the library reports quality-mismatch unless the legacy audiobookQuality column is already populated (rare for fresh imports).
  • The "Below Cutoff" UI label is meaningless — it fires for files that perfectly meet the cutoff.
  • Affects all default profiles seeded via the UI as well as profiles created via API.

Proposed fix

DeriveQualityLabel should produce labels that round-trip with QualityProfile.Qualities[i].Quality:

  • For lossless containers/codecs, return the codec name itself: "flac", "alac", "wav", etc., not "lossless".
  • For lossy files, return "<codec> <bucket>kbps" with the codec inferred from Codec/Container/Format ("mp3 320kbps", "aac 192kbps", "opus 128kbps").

Patch (running locally as a test):

private static string DeriveQualityLabel(AudiobookFileStatusInfo? file, string? audiobookQuality)
{
    var normalizedAudiobookQuality = Normalize(audiobookQuality);
    if (normalizedAudiobookQuality.Length > 0) return normalizedAudiobookQuality;

    var container = Normalize(file?.Container);
    var codec = Normalize(file?.Codec);
    var format = Normalize(file?.Format);

    if (container.Contains("flac", StringComparison.Ordinal) || codec.Contains("flac", StringComparison.Ordinal)) return "flac";
    if (container.Contains("alac", StringComparison.Ordinal) || codec.Contains("alac", StringComparison.Ordinal)) return "alac";
    if (container.Contains("wav",  StringComparison.Ordinal) || codec.Contains("wav",  StringComparison.Ordinal)) return "wav";
    // ... (aiff/ape/dsd/wavpack analogous)

    string codecName = "";
    if      (codec.Contains("mp3")    || container.Contains("mp3")  || format.Contains("mp3"))                              codecName = "mp3";
    else if (codec.Contains("aac")    || container.Contains("m4a")  || container.Contains("m4b") || container.Contains("mp4")) codecName = "aac";
    else if (codec.Contains("opus")   || container.Contains("opus")) codecName = "opus";
    else if (codec.Contains("vorbis") || container.Contains("ogg"))  codecName = "ogg vorbis";

    if (file?.Bitrate is int bitrate)
    {
        var kbps = bitrate >= 1000 ? bitrate / 1000d : bitrate;
        int bucket = kbps >= 320 ? 320 : kbps >= 256 ? 256 : kbps >= 192 ? 192 : kbps >= 128 ? 128 : kbps >= 96 ? 96 : kbps >= 64 ? 64 : (int)Math.Round(kbps);
        return codecName.Length > 0 ? $"{codecName} {bucket}kbps" : $"{bucket}kbps";
    }
    return codecName.Length > 0 ? codecName : format;
}

Bucket cutoffs match the existing schema seeded by qualityprofile/default-quality-formats.

Happy to send a PR if the above approach is acceptable. Currently testing this patched evaluator against a 48-book library on canary 0.2.73.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions