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
49 changes: 46 additions & 3 deletions src/Sleezer/Core/PostProcessing/CorruptionScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ public async Task<Result> 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);
Expand Down Expand Up @@ -138,6 +146,35 @@ public async Task<Result> ScanAsync(string path, int timeoutSeconds, Cancellatio
}
}

/// <summary>
/// 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.
/// </summary>
private async Task<Result> 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);
}

/// <summary>
/// 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
Expand Down Expand Up @@ -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();

Expand All @@ -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");
Expand Down
15 changes: 15 additions & 0 deletions src/Sleezer/Core/PostProcessing/FfmpegErrorFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

/// <summary>
/// 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.
/// </summary>
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));

/// <summary>
/// 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.
Expand Down
39 changes: 27 additions & 12 deletions src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public AlbumData CreateAlbumData(string searchId, IGrouping<string, SlskdFileDat
// in the folder's filenames, the folder IS the album regardless of
// how it is named. Essential for server-blocked artists whose name
// never appears in the query or the path.
(List<SlskdFileData> matchedTrackFiles, int coveredTrackCount, int wantedTrackTitleCount) = MatchWantedTrackFiles(directory, searchData.Tracks);
(List<SlskdFileData> matchedTrackFiles, int coveredTrackCount, int wantedTrackTitleCount) = MatchWantedTrackFiles(directory, searchData.Tracks, searchData.TargetVariantTypes);
double trackEvidence = wantedTrackTitleCount > 0 ? coveredTrackCount / (double)wantedTrackTitleCount : 0;
if (trackEvidence >= TrackEvidenceThreshold)
isAlbumMatch = true;
Expand All @@ -134,16 +134,15 @@ public AlbumData CreateAlbumData(string searchId, IGrouping<string, SlskdFileDat

// Runs after every album-match route including track evidence: a
// remix single's filenames CONTAIN the base title, so evidence
// would otherwise force the false match right back. Checks the
// parent component too — "Album (Live)\FLAC" layouts carry the
// qualifier one level up from the group key's leaf.
// would otherwise force the false match right back. Leaf AND parent
// are judged as one candidate — "Album (Live)\FLAC" carries the
// qualifier one level up.
if (isAlbumMatch && !string.IsNullOrEmpty(searchData.Album))
{
string[] pathComponents = SplitPathIntoComponents(directory.Key);
string candidateLeaf = pathComponents.Length > 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;
Expand Down Expand Up @@ -696,21 +695,30 @@ private static bool IsAudioFile(SlskdFileData f)
/// titles long enough to match on (the track-evidence denominator).
/// </summary>
private static (List<SlskdFileData> MatchedFiles, int CoveredTracks, int WantedTitles) MatchWantedTrackFiles(
IEnumerable<SlskdFileData> files, List<string>? expectedTracks)
IEnumerable<SlskdFileData> files, List<string>? expectedTracks, IReadOnlyCollection<string>? targetVariantTypes = null)
{
List<SlskdFileData> matched = [];
if (expectedTracks == null || expectedTracks.Count == 0)
return (matched, 0, 0);

List<string> 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)
Expand All @@ -721,12 +729,19 @@ private static (List<SlskdFileData> MatchedFiles, int CoveredTracks, int WantedT
// "Falling Slowly") and make an incomplete source look complete.
HashSet<int> 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)
Expand Down
59 changes: 57 additions & 2 deletions src/Sleezer/Indexers/Soulseek/SlskdTextProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public static bool RemixSignaturesConflict(string? searchAlbum, string? candidateName, IReadOnlyCollection<string>? targetSecondaryTypes)
public static bool RemixSignaturesConflict(string? searchAlbum, string? candidateName, IReadOnlyCollection<string>? targetSecondaryTypes) =>
RemixSignaturesConflict(searchAlbum, [candidateName], targetSecondaryTypes);

/// <summary>
/// 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.
/// </summary>
public static bool RemixSignaturesConflict(string? searchAlbum, IReadOnlyList<string?> candidateComponents, IReadOnlyCollection<string>? 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");
Expand Down Expand Up @@ -466,6 +479,48 @@ public static bool RemixSignaturesConflict(string? searchAlbum, string? candidat
return Fuzz.TokenSetRatio(searchSignature, candidateSignature) < 60;
}

/// <summary>
/// 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.
/// </summary>
private static VariantProfile UnionVariantProfiles(IReadOnlyList<string?> 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);
}

/// <summary>
/// True when a title carries ANY variant qualifier. Cheap pre-filter so
/// callers only pay for <see cref="RemixSignaturesConflict(string?, string?, IReadOnlyCollection{string}?)"/>
/// on pairs where one side is decorated — plain-vs-plain can never conflict.
/// </summary>
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<string>? types, string name) =>
types != null && types.Any(t => string.Equals(t, name, StringComparison.OrdinalIgnoreCase));

Expand Down
Loading
Loading