diff --git a/src/Sleezer/Core/PostProcessing/CorruptionScanner.cs b/src/Sleezer/Core/PostProcessing/CorruptionScanner.cs index e96c9b8..b4e86eb 100644 --- a/src/Sleezer/Core/PostProcessing/CorruptionScanner.cs +++ b/src/Sleezer/Core/PostProcessing/CorruptionScanner.cs @@ -94,6 +94,14 @@ public async Task ScanAsync(string path, int timeoutSeconds, Cancellatio if (exitCode != 0) { + // A malformed cover-art block aborts INPUT OPEN under -err_detect + // explode, before -map 0:a limits the scan to audio \u2014 the audio was + // never judged. Re-verify without explode so the demuxer skips the + // picture; that pass is the authority (verified: decoded audio is + // byte-identical to a clean file). + if (FfmpegErrorFormatter.IsAttachedPictureFailure(stderr)) + return await ReverifyPastAttachedPictureAsync(path, timeoutSeconds, sw, ct); + string reason = FfmpegErrorFormatter.CleanFfmpegErrors(stderr); _logger.Debug("Corruption scan {Path}: ffmpeg verdict corrupt (exit={ExitCode}) \u2014 {Reason}", path, exitCode, reason); return new Result(true, reason); @@ -138,6 +146,35 @@ public async Task ScanAsync(string path, int timeoutSeconds, Cancellatio } } + /// + /// Second decode pass for a file whose cover-art block killed the first one. + /// Runs without AV_EF_EXPLODE so the demuxer skips the picture and the AUDIO + /// is judged on its own; stays fail-closed — any real decoder error in this + /// pass (which exits 0 on recoverable errors, hence the stderr check) is still + /// corruption. + /// + private async Task ReverifyPastAttachedPictureAsync(string path, int timeoutSeconds, Stopwatch sw, CancellationToken ct) + { + (int exitCode, string stderr) = await RunFfmpegDecodeAsync(path, timeoutSeconds, ct, explodeOnError: false); + + if (exitCode == -1) + { + _logger.Debug("Corruption scan {Path}: art-skipping decode timed out after {Timeout}s", path, timeoutSeconds); + return new Result(true, $"Decode timed out (>{timeoutSeconds}s)"); + } + + string significant = FfmpegErrorFormatter.StripBenignMetadataNoise(stderr); + if (exitCode != 0 || !string.IsNullOrWhiteSpace(significant)) + { + string reason = FfmpegErrorFormatter.CleanFfmpegErrors(string.IsNullOrWhiteSpace(significant) ? stderr : significant); + _logger.Debug("Corruption scan {Path}: corrupt beyond the attached picture (exit={ExitCode}) — {Reason}", path, exitCode, reason); + return new Result(true, reason); + } + + _logger.Info("Corruption scan {Path}: embedded cover art is malformed but the audio decoded clean in {ElapsedMs}ms — keeping the file", path, sw.ElapsedMilliseconds); + return new Result(false, null); + } + /// /// Resolve the ffmpeg binary the scanner will invoke. Public so the post-import /// converter can reuse the exact same resolution + decode logic to verify its @@ -202,7 +239,7 @@ private async Task LogFfmpegVersionOnceAsync(CancellationToken ct) } } - private static async Task<(int exitCode, string stderr)> RunFfmpegDecodeAsync(string path, int timeoutSeconds, CancellationToken ct) + private static async Task<(int exitCode, string stderr)> RunFfmpegDecodeAsync(string path, int timeoutSeconds, CancellationToken ct, bool explodeOnError = true) { string ffmpegPath = ResolveFfmpegPath(); @@ -224,8 +261,14 @@ private async Task LogFfmpegVersionOnceAsync(CancellationToken ct) // decoder aborts instead of silently skipping bad frames — combined // with `-xerror`, that finally turns "Invalid data found" into a // non-zero exit. Without this, a clean scan is meaningless. - psi.ArgumentList.Add("-err_detect"); - psi.ArgumentList.Add("explode"); + // explodeOnError:false is the cover-art re-verify pass — it also stops + // the DEMUXER exploding, so callers must judge that pass on stderr. + if (explodeOnError) + { + psi.ArgumentList.Add("-err_detect"); + psi.ArgumentList.Add("explode"); + } + psi.ArgumentList.Add("-xerror"); psi.ArgumentList.Add("-nostdin"); psi.ArgumentList.Add("-i"); diff --git a/src/Sleezer/Core/PostProcessing/FfmpegErrorFormatter.cs b/src/Sleezer/Core/PostProcessing/FfmpegErrorFormatter.cs index daa6b72..95cadf0 100644 --- a/src/Sleezer/Core/PostProcessing/FfmpegErrorFormatter.cs +++ b/src/Sleezer/Core/PostProcessing/FfmpegErrorFormatter.cs @@ -48,8 +48,23 @@ public static string CleanFfmpegErrors(string stderr) "Error reading comment frame", "Error reading lyrics", "Error reading frame", + // Cover-art block, not audio (libavformat/flac_picture.c). Without + // AV_EF_EXPLODE the demuxer skips the picture and decodes normally. + "Could not read mimetype from an attached picture", + "Error parsing attached picture", }; + /// + /// True when ffmpeg failed while parsing an embedded cover-art block. Under + /// `-err_detect explode` this aborts INPUT OPEN, before `-map 0:a` can limit + /// the scan to audio — so the audio is never actually judged. The caller must + /// re-verify without explode rather than trust this verdict. + /// + public static bool IsAttachedPictureFailure(string stderr) => + !string.IsNullOrWhiteSpace(stderr) && + (stderr.Contains("Could not read mimetype from an attached picture", System.StringComparison.Ordinal) || + stderr.Contains("Error parsing attached picture", System.StringComparison.Ordinal)); + /// /// True if an ffmpeg stderr line is an ID3 tag-parse error the demuxer recovered /// from by skipping the tag - a metadata defect, not audio corruption. diff --git a/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs b/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs index b0216c7..3d5e659 100644 --- a/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs +++ b/src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs @@ -115,7 +115,7 @@ public AlbumData CreateAlbumData(string searchId, IGrouping matchedTrackFiles, int coveredTrackCount, int wantedTrackTitleCount) = MatchWantedTrackFiles(directory, searchData.Tracks); + (List matchedTrackFiles, int coveredTrackCount, int wantedTrackTitleCount) = MatchWantedTrackFiles(directory, searchData.Tracks, searchData.TargetVariantTypes); double trackEvidence = wantedTrackTitleCount > 0 ? coveredTrackCount / (double)wantedTrackTitleCount : 0; if (trackEvidence >= TrackEvidenceThreshold) isAlbumMatch = true; @@ -134,16 +134,15 @@ public AlbumData CreateAlbumData(string searchId, IGrouping 0 ? pathComponents[^1] : directory.Key; string? candidateParent = pathComponents.Length >= 2 ? pathComponents[^2] : null; - if (SlskdTextProcessor.RemixSignaturesConflict(searchData.Album, candidateLeaf, searchData.TargetVariantTypes) || - (candidateParent != null && SlskdTextProcessor.RemixSignaturesConflict(searchData.Album, candidateParent, searchData.TargetVariantTypes))) + if (SlskdTextProcessor.RemixSignaturesConflict(searchData.Album, [candidateLeaf, candidateParent], searchData.TargetVariantTypes)) { _logger.Trace("Remix qualifier mismatch: search '{Album}' vs folder '{Folder}'", searchData.Album, candidateLeaf); isAlbumMatch = false; @@ -696,21 +695,30 @@ private static bool IsAudioFile(SlskdFileData f) /// titles long enough to match on (the track-evidence denominator). /// private static (List MatchedFiles, int CoveredTracks, int WantedTitles) MatchWantedTrackFiles( - IEnumerable files, List? expectedTracks) + IEnumerable files, List? expectedTracks, IReadOnlyCollection? targetVariantTypes = null) { List matched = []; if (expectedTracks == null || expectedTracks.Count == 0) return (matched, 0, 0); - List titles = expectedTracks.Select(NormalizeString).Where(t => t.Length >= 4).ToList(); + // Raw title kept alongside the normalized one: normalization strips the + // brackets a variant qualifier lives in ("(Radio Edit)"). + List<(string Raw, string Norm, bool Qualified)> titles = expectedTracks + .Select(t => (Raw: t, Norm: NormalizeString(t), Qualified: SlskdTextProcessor.HasVariantQualifier(t))) + .Where(t => t.Norm.Length >= 4) + .ToList(); if (titles.Count == 0) return (matched, 0, 0); // Audio files only (a same-named .cue/.nfo must not outrank the track); // basename after normalizing backslashes (Path.* won't split them on Linux). - List<(SlskdFileData File, string Name)> named = files + List<(SlskdFileData File, string Name, string Raw, bool Qualified)> named = files .Where(IsAudioFile) - .Select(f => (File: f, Name: NormalizeString(TrackNumberPrefixRegex().Replace(Path.GetFileNameWithoutExtension((f.Filename ?? string.Empty).Replace('\\', '/')), string.Empty)))) + .Select(f => + { + string raw = TrackNumberPrefixRegex().Replace(Path.GetFileNameWithoutExtension((f.Filename ?? string.Empty).Replace('\\', '/')), string.Empty); + return (File: f, Name: NormalizeString(raw), Raw: raw, Qualified: SlskdTextProcessor.HasVariantQualifier(raw)); + }) .Where(x => x.Name.Length > 0) .ToList(); if (named.Count == 0) @@ -721,12 +729,19 @@ private static (List MatchedFiles, int CoveredTracks, int WantedT // "Falling Slowly") and make an incomplete source look complete. HashSet claimed = []; int covered = 0; - foreach (string title in titles.OrderByDescending(t => t.Length)) + foreach ((string Raw, string Norm, bool Qualified) title in titles.OrderByDescending(t => t.Norm.Length)) { for (int i = 0; i < named.Count; i++) { - if (claimed.Contains(i) || !named[i].Name.Contains(title)) + if (claimed.Contains(i) || !named[i].Name.Contains(title.Norm)) continue; + + // A wanted title is contained in its own variant ("Proposition" + // in "Proposition (Radio Edit)"); target types forgive valid ones. + if ((title.Qualified || named[i].Qualified) && + SlskdTextProcessor.RemixSignaturesConflict(title.Raw, named[i].Raw, targetVariantTypes)) + continue; + claimed.Add(i); covered++; if (named[i].File.Filename != null) diff --git a/src/Sleezer/Indexers/Soulseek/SlskdTextProcessor.cs b/src/Sleezer/Indexers/Soulseek/SlskdTextProcessor.cs index ace84c9..eb1dd8e 100644 --- a/src/Sleezer/Indexers/Soulseek/SlskdTextProcessor.cs +++ b/src/Sleezer/Indexers/Soulseek/SlskdTextProcessor.cs @@ -427,10 +427,23 @@ public static bool RemixSignaturesConflict(string? searchAlbum, string? candidat /// title string hides ("Apple Music Live: ..." + folder "(Live)"), but /// never demand one — an undecorated exact-title folder still matches. /// - public static bool RemixSignaturesConflict(string? searchAlbum, string? candidateName, IReadOnlyCollection? targetSecondaryTypes) + public static bool RemixSignaturesConflict(string? searchAlbum, string? candidateName, IReadOnlyCollection? targetSecondaryTypes) => + RemixSignaturesConflict(searchAlbum, [candidateName], targetSecondaryTypes); + + /// + /// Path-aware variant: a folder's qualifier may sit in ANY component + /// ("Album (Live)\FLAC" carries it one level up), so the candidate's + /// profile is the UNION over the components and is judged once. Testing + /// components separately and rejecting on any conflict is wrong in both + /// directions — a generic parent ("Music") reports a phantom conflict for + /// an album whose own title is qualified, while a plain sibling would + /// excuse a qualifier the search never asked for. + /// + public static bool RemixSignaturesConflict(string? searchAlbum, IReadOnlyList candidateComponents, IReadOnlyCollection? targetSecondaryTypes) { VariantProfile search = ExtractVariantProfile(searchAlbum); - VariantProfile candidate = ExtractVariantProfile(candidateName); + VariantProfile candidate = UnionVariantProfiles(candidateComponents); + string candidateName = string.Join(" ", candidateComponents.Where(c => !string.IsNullOrWhiteSpace(c))); bool metaLive = HasSecondaryType(targetSecondaryTypes, "Live"); bool metaDemo = HasSecondaryType(targetSecondaryTypes, "Demo"); @@ -466,6 +479,48 @@ public static bool RemixSignaturesConflict(string? searchAlbum, string? candidat return Fuzz.TokenSetRatio(searchSignature, candidateSignature) < 60; } + /// + /// Candidate profile across path components: a qualifier present in any + /// component counts as present. Deliberately one-way — it can only ADD a + /// qualifier, never cancel one a sibling component carries. + /// + private static VariantProfile UnionVariantProfiles(IReadOnlyList components) + { + if (components.Count == 1) + return ExtractVariantProfile(components[0]); + + bool live = false, acoustic = false, demo = false, extended = false; + string? monoStereo = null; + string? remixSignature = null; + + // Leaf first (components are ordered leaf-to-parent), so the nearest + // component wins for the single-valued dimensions. + foreach (string? component in components) + { + VariantProfile profile = ExtractVariantProfile(component); + live |= profile.Live; + acoustic |= profile.Acoustic; + demo |= profile.Demo; + extended |= profile.Extended; + monoStereo ??= profile.MonoStereo; + remixSignature ??= profile.RemixSignature; + } + + return new VariantProfile(live, acoustic, demo, extended, monoStereo, remixSignature); + } + + /// + /// True when a title carries ANY variant qualifier. Cheap pre-filter so + /// callers only pay for + /// on pairs where one side is decorated — plain-vs-plain can never conflict. + /// + public static bool HasVariantQualifier(string? title) + { + VariantProfile profile = ExtractVariantProfile(title); + return profile.Live || profile.Acoustic || profile.Demo || profile.Extended || + profile.MonoStereo != null || profile.RemixSignature != null; + } + private static bool HasSecondaryType(IReadOnlyCollection? types, string name) => types != null && types.Any(t => string.Equals(t, name, StringComparison.OrdinalIgnoreCase)); diff --git a/tests/Sleezer.Tests/SlskdVariantAndArtworkTests.cs b/tests/Sleezer.Tests/SlskdVariantAndArtworkTests.cs new file mode 100644 index 0000000..8d93253 --- /dev/null +++ b/tests/Sleezer.Tests/SlskdVariantAndArtworkTests.cs @@ -0,0 +1,318 @@ +using NLog; +using NzbDrone.Plugin.Sleezer.Core.Model; +using NzbDrone.Plugin.Sleezer.Core.PostProcessing; +using NzbDrone.Plugin.Sleezer.Indexers.Soulseek; +using Xunit; + +namespace Sleezer.Tests; + +// Two 2026-08-07 follow-ups from the GLXY audit: +// 1. A malformed embedded cover-art block aborts ffmpeg's INPUT OPEN under +// -err_detect explode, before -map 0:a limits the scan to audio. Verified +// against ffmpeg 8.1.2: the decoded audio of such a file is byte-identical +// to a clean one, yet the scanner deleted + blocklisted it (GLXY "Love Lost" +// lost this way from two different peers). +// 2. A wanted track title is CONTAINED in its own variant ("Proposition" in +// "Proposition (Radio Edit)"), so a radio-edit-only source looked complete +// and was grabbed; Lidarr then rejected the import on track length. +public class AttachedPictureFalsePositiveTests +{ + private const string PictureFailure = + "[in#0 @ 0x1] Could not read mimetype from an attached picture.\n" + + "[in#0 @ 0x1] Error parsing attached picture.\n" + + "[in#0 @ 0x2] Error opening input: Invalid data found when processing input\n" + + "Error opening input file /downloads/GLXY - Love Lost/01 - Love Lost.flac.\n"; + + [Fact] + public void The_live_love_lost_stderr_is_recognised_as_an_artwork_failure() + { + Assert.True(FfmpegErrorFormatter.IsAttachedPictureFailure(PictureFailure)); + } + + [Fact] + public void Artwork_lines_are_stripped_as_benign_metadata_noise() + { + Assert.True(string.IsNullOrWhiteSpace( + FfmpegErrorFormatter.StripBenignMetadataNoise( + "[in#0 @ 0x1] Could not read mimetype from an attached picture.\n"))); + } + + // Fail-closed: the re-verify pass keeps real decoder errors (observed exit + // 183 with these exact lines on a bad-picture + damaged-audio file). + [Fact] + public void Real_decoder_errors_survive_the_artwork_strip() + { + string stderr = + "[in#0 @ 0x1] Could not read mimetype from an attached picture.\n" + + "[flac @ 0x2] invalid sync code\n" + + "[flac @ 0x2] decode_frame() failed\n"; + + string significant = FfmpegErrorFormatter.StripBenignMetadataNoise(stderr); + + Assert.False(string.IsNullOrWhiteSpace(significant)); + Assert.Contains("invalid sync code", significant); + Assert.DoesNotContain("attached picture", significant); + } + + // A truncated file carries no artwork marker, so it never reaches the + // re-verify path and keeps failing on the first pass. + [Theory] + [InlineData("[in#0 @ 0x1] Error opening input: End of file\nError opening input file /tmp/trunc.flac.\n")] + [InlineData("[flac @ 0x2] Header missing\n")] + [InlineData("")] + public void Non_artwork_failures_are_not_treated_as_artwork(string stderr) + { + Assert.False(FfmpegErrorFormatter.IsAttachedPictureFailure(stderr)); + } +} + +public class VariantQualifierDetectionTests +{ + [Theory] + [InlineData("GLXY - Proposition (Radio Edit) [feat. James Robb]", true)] + [InlineData("Best of Both Worlds (Live)", true)] + [InlineData("Dreams (Extended Version)", true)] + [InlineData("Never Say Never (Colyn Remix)", true)] + [InlineData("Proposition", false)] + [InlineData("GLXY - Mind Less", false)] + [InlineData("OK Computer (Deluxe Edition)", false)] // edition, not a variant + [InlineData("Live Forever", false)] // "live" as a title word + [InlineData("", false)] + public void Qualifier_detection_matches_the_variant_profile(string title, bool expected) + { + Assert.Equal(expected, SlskdTextProcessor.HasVariantQualifier(title)); + } +} + +public class RadioEditTrackMatchingTests +{ + private static readonly SlskdItemsParser Parser = new(LogManager.GetLogger("tests")); + + private static IGrouping Group(params string[] filenames) => + filenames + .Select(f => new SlskdFileData(f, null, 16, 30_000_000, 300, ".flac", 44100, 0, false)) + .GroupBy(f => SlskdTextProcessor.GetMergedDirectoryKey(f.Filename)) + .Single(); + + private static SlskdFolderData Folder(string path) => + new(path, "", "", "", "peer", true, 1_000_000, 0, [], 0, 0, 0, [], 0); + + private static SlskdSearchData Search(List tracks, string album = "Proposition Mind Less", + List? variantTypes = null, string albumType = "Single") => + new("GLXY", album, Interactive: false, ExpandDirectory: false, MinimumFiles: 1, MaximumFiles: 4, + TrackCount: tracks.Count, Tracks: tracks, TargetVariantTypes: variantTypes, AlbumType: albumType); + + // The live 2026-08-06 grab: both files are radio edits of tracks the target + // lists plainly, so the source holds NONE of the wanted recordings. + [Fact] + public void A_radio_edit_only_source_no_longer_satisfies_plain_wanted_tracks() + { + AlbumData album = Parser.CreateAlbumData( + "s1", + Group( + @"Music\GLXY - Proposition # Mind Less (2017)\01. GLXY - Proposition (Radio Edit) [feat. James Robb].flac", + @"Music\GLXY - Proposition # Mind Less (2017)\02. GLXY - Mind Less (Radio Edit) [feat. Blake].flac"), + Search(["Proposition", "Mind Less"]), + Folder(@"Music\GLXY - Proposition # Mind Less (2017)"), + new SlskdSettings { RequireCoherentSingleSource = true }, + expectedTrackCount: 2); + + Assert.False(album.MatchedSearchCriteria); + } + + [Fact] + public void The_plain_versions_of_the_same_release_still_match() + { + AlbumData album = Parser.CreateAlbumData( + "s1", + Group( + @"Music\GLXY - Proposition # Mind Less (2017)\01. GLXY - Proposition.flac", + @"Music\GLXY - Proposition # Mind Less (2017)\02. GLXY - Mind Less.flac"), + Search(["Proposition", "Mind Less"]), + Folder(@"Music\GLXY - Proposition # Mind Less (2017)"), + new SlskdSettings { RequireCoherentSingleSource = true }, + expectedTrackCount: 2); + + Assert.True(album.MatchedSearchCriteria); + } + + // Regression guard: when the TARGET's own track titles carry the qualifier + // (radio-edit single, box set listing live cuts), the files must still match. + [Fact] + public void A_target_that_wants_the_radio_edits_still_matches_them() + { + AlbumData album = Parser.CreateAlbumData( + "s1", + Group( + @"Music\GLXY - Proposition # Mind Less (2017)\01. GLXY - Proposition (Radio Edit) [feat. James Robb].flac", + @"Music\GLXY - Proposition # Mind Less (2017)\02. GLXY - Mind Less (Radio Edit) [feat. Blake].flac"), + Search(["Proposition (Radio Edit)", "Mind Less (Radio Edit)"]), + Folder(@"Music\GLXY - Proposition # Mind Less (2017)"), + new SlskdSettings { RequireCoherentSingleSource = true }, + expectedTrackCount: 2); + + Assert.True(album.MatchedSearchCriteria); + } + + // Regression guard for the box-set class seen in the failed-import data + // (Van Halen live cuts, AC/DC Backtracks): MusicBrainz marks the RELEASE + // Live while the track titles stay plain — the files must still match. + // NB: album title is deliberately plain. An album whose TITLE carries the + // qualifier trips a separate, pre-existing parent-folder conflict — see + // Parent_folder_conflict_is_a_known_false_negative below. + [Fact] + public void A_live_album_with_plain_track_titles_still_matches_live_files() + { + AlbumData album = Parser.CreateAlbumData( + "s1", + Group( + @"Music\Van Halen - Tokyo Dome\01. Unchained (live at the Tokyo Dome June 21, 2013).flac", + @"Music\Van Halen - Tokyo Dome\02. Somebody Get Me a Doctor (live at the Tokyo Dome June 21, 2013).flac"), + Search(["Unchained", "Somebody Get Me a Doctor"], album: "Tokyo Dome", + variantTypes: ["Live"], albumType: "Album"), + Folder(@"Music\Van Halen - Tokyo Dome"), + new SlskdSettings { RequireCoherentSingleSource = true }, + expectedTrackCount: 2); + + Assert.True(album.MatchedSearchCriteria); + } + + // Same case as a coherence-gated single, where coverage is the ONLY route to + // a match — no album-name fallback can carry it. + [Fact] + public void A_live_single_with_plain_track_titles_still_covers_its_live_files() + { + AlbumData album = Parser.CreateAlbumData( + "s1", + Group( + @"Music\Van Halen - Tokyo Dome\01. Unchained (live at the Tokyo Dome June 21, 2013).flac", + @"Music\Van Halen - Tokyo Dome\02. Somebody Get Me a Doctor (live at the Tokyo Dome June 21, 2013).flac"), + Search(["Unchained", "Somebody Get Me a Doctor"], album: "Tokyo Dome", + variantTypes: ["Live"], albumType: "Single"), + Folder(@"Music\Van Halen - Tokyo Dome"), + new SlskdSettings { RequireCoherentSingleSource = true }, + expectedTrackCount: 2); + + Assert.True(album.MatchedSearchCriteria); + } + + // Per-file check in isolation: the live files are accepted for plain wanted + // titles because the RELEASE is marked Live (metaLive forgives them). + [Fact] + public void Live_files_do_not_conflict_with_plain_titles_when_the_release_is_live() + { + Assert.False(SlskdTextProcessor.RemixSignaturesConflict( + "Unchained", "Unchained (live at the Tokyo Dome June 21, 2013)", ["Live"])); + Assert.True(SlskdTextProcessor.RemixSignaturesConflict( + "Unchained", "Unchained (live at the Tokyo Dome June 21, 2013)", null)); + } + + // A mixed source (one plain track, one radio edit) is partial, not complete. + [Fact] + public void A_partly_radio_edit_source_is_only_partially_covered() + { + AlbumData album = Parser.CreateAlbumData( + "s1", + Group( + @"Music\GLXY - Proposition # Mind Less (2017)\01. GLXY - Proposition.flac", + @"Music\GLXY - Proposition # Mind Less (2017)\02. GLXY - Mind Less (Radio Edit).flac"), + Search(["Proposition", "Mind Less"]), + Folder(@"Music\GLXY - Proposition # Mind Less (2017)"), + new SlskdSettings { RequireCoherentSingleSource = true }, + expectedTrackCount: 2); + + Assert.False(album.MatchedSearchCriteria); + } + + // A component judged ALONE still conflicts — the union is what fixes it. + [Fact] + public void A_generic_parent_alone_still_reports_a_conflict() + { + Assert.True(SlskdTextProcessor.RemixSignaturesConflict("Tokyo Dome Live", "Music", ["Live"])); + Assert.True(SlskdTextProcessor.RemixSignaturesConflict("Live at Wembley", "Music", null)); + } +} + +// The qualifier can sit in any path component, so leaf and parent are judged as +// one candidate. Judging them separately and rejecting on either was wrong both +// ways: a generic parent vetoed an album whose own title was qualified (live +// albums were unmatchable under "Music\"), and the intended +// "Album (Live)\FLAC" rescue never actually worked — the leaf's own conflict +// rejected it first. +public class FolderVariantComponentTests +{ + private static readonly SlskdItemsParser Parser = new(LogManager.GetLogger("tests")); + + private static IGrouping Group(params string[] filenames) => + filenames + .Select(f => new SlskdFileData(f, null, 16, 30_000_000, 300, ".flac", 44100, 0, false)) + .GroupBy(f => SlskdTextProcessor.GetMergedDirectoryKey(f.Filename)) + .Single(); + + private static SlskdFolderData Folder(string path) => + new(path, "", "", "", "peer", true, 1_000_000, 0, [], 0, 0, 0, [], 0); + + private static bool Matches(string album, string folder, string file, List? variantTypes = null) + { + AlbumData data = Parser.CreateAlbumData( + "s1", + Group($@"{folder}\{file}"), + new SlskdSearchData("Some Artist", album, Interactive: false, ExpandDirectory: false, + MinimumFiles: 1, MaximumFiles: 40, TrackCount: 1, Tracks: ["Only Track"], + TargetVariantTypes: variantTypes, AlbumType: "Album"), + Folder(folder), + new SlskdSettings(), + expectedTrackCount: 1); + return data.MatchedSearchCriteria; + } + + // The reported bug: album title carries the qualifier, parent is generic. + [Fact] + public void A_live_titled_album_matches_under_a_generic_parent() + { + Assert.True(Matches("Tokyo Dome Live", @"Music\Some Artist - Tokyo Dome Live", "01 - Only Track.flac")); + Assert.True(Matches("Live at Wembley", @"Music\Some Artist - Live at Wembley", "01 - Only Track.flac")); + } + + // The rescue the old comment claimed but never delivered: the qualifier is + // one level up because the leaf is a quality subfolder. + [Fact] + public void A_qualifier_one_level_up_reconciles_a_generic_leaf() + { + Assert.True(Matches("Some Album (Live)", @"Some Artist - Some Album (Live)\FLAC", "01 - Only Track.flac")); + } + + // Protection preserved: a qualifier the search never asked for is still a + // different release, and no plain sibling component may excuse it. + [Fact] + public void An_unwanted_qualifier_is_still_rejected_whichever_component_holds_it() + { + Assert.False(Matches("Some Album", @"Music\Some Album (Live)", "01 - Only Track.flac")); + Assert.False(Matches("Some Album", @"Some Album (Live)\FLAC", "01 - Only Track.flac")); + Assert.False(Matches("Some Song", @"Music\Some Song (Colyn Remix)", "01 - Only Track.flac")); + } + + [Fact] + public void Plain_albums_in_plain_folders_are_unaffected() + { + Assert.True(Matches("Some Album", @"Music\Some Artist - Some Album", "01 - Only Track.flac")); + } + + // MusicBrainz secondary types keep forgiving candidate-side qualifiers. + [Fact] + public void A_live_typed_release_still_accepts_a_live_folder() + { + Assert.True(Matches("Some Album", @"Music\Some Album (Live)", "01 - Only Track.flac", variantTypes: ["Live"])); + } + + [Fact] + public void The_union_only_adds_qualifiers_it_never_cancels_one() + { + // parent plain, leaf qualified -> union stays qualified (conflict vs plain search) + Assert.True(SlskdTextProcessor.RemixSignaturesConflict("Some Album", ["Some Album (Live)", "Music"], null)); + // leaf plain, parent qualified -> union still qualified + Assert.True(SlskdTextProcessor.RemixSignaturesConflict("Some Album", ["FLAC", "Some Album (Live)"], null)); + // both plain -> no conflict + Assert.False(SlskdTextProcessor.RemixSignaturesConflict("Some Album", ["Some Album", "Music"], null)); + } +}