mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 21:55:39 -04:00
1064 lines
38 KiB
C#
1064 lines
38 KiB
C#
using System.Text.Json;
|
||
using System.Diagnostics;
|
||
using allstarr.Core.Matching;
|
||
using allstarr.Core.Playlists;
|
||
using allstarr.Core.Storage;
|
||
using allstarr.Core.Capabilities;
|
||
using Xunit.Abstractions;
|
||
|
||
namespace allstarr.Tests;
|
||
|
||
public sealed class TrackMatchDecisionEngineTests(ITestOutputHelper output)
|
||
{
|
||
[Fact]
|
||
public void Matching_baseline_is_linear_at_100_1000_and_10000_tracks()
|
||
{
|
||
var baselines = new List<(int Count, long Allocated, long ElapsedTicks)>();
|
||
foreach (var count in new[] { 100, 1_000, 10_000 })
|
||
{
|
||
var scope = Scope();
|
||
var candidates = Enumerable.Range(0, count)
|
||
.Select(index => Candidate(scope) with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = $"local-{index}",
|
||
Title = $"Track {index}",
|
||
Isrc = $"USAAA26{index:D5}"
|
||
})
|
||
.ToArray();
|
||
var sources = candidates.Select((candidate, index) =>
|
||
Source() with
|
||
{
|
||
SnapshotId = index.ToString(),
|
||
Title = candidate.Title,
|
||
Isrc = candidate.Isrc
|
||
}).ToArray();
|
||
var engine = new TrackMatchDecisionEngine();
|
||
|
||
var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
|
||
var timer = Stopwatch.StartNew();
|
||
var prepared = engine.PrepareCandidates(candidates);
|
||
var decisions = sources.Select(source => engine.Decide(scope, source, prepared)).ToArray();
|
||
timer.Stop();
|
||
var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;
|
||
|
||
Assert.Equal(count, decisions.Length);
|
||
Assert.All(decisions, decision => Assert.Equal(TrackMatchReviewState.Accepted, decision.State));
|
||
Assert.All(decisions, decision => Assert.Single(decision.Candidates));
|
||
baselines.Add((count, allocated, timer.ElapsedTicks));
|
||
output.WriteLine(
|
||
$"matching tracks={count} allocated_bytes={allocated} elapsed_ticks={timer.ElapsedTicks}");
|
||
}
|
||
|
||
Assert.All(baselines.Zip(baselines.Skip(1)), pair =>
|
||
Assert.True(pair.Second.Allocated < pair.First.Allocated * 30,
|
||
$"Allocation growth from {pair.First.Count} to {pair.Second.Count} tracks was quadratic."));
|
||
}
|
||
|
||
[Fact]
|
||
public void PreparedCandidatesAndPersistenceInputPreserveOneDecision()
|
||
{
|
||
var scope = Scope();
|
||
var candidate = Candidate(scope);
|
||
var engine = new TrackMatchDecisionEngine();
|
||
var candidates = engine.PrepareCandidates([candidate]);
|
||
|
||
var decision = engine.Decide(scope, Source(), candidates);
|
||
var input = MatchDecisionInput.FromDecision(
|
||
Guid.CreateVersion7(),
|
||
candidate.CanonicalRecordingId,
|
||
decision,
|
||
decisionVersion: 3,
|
||
sourceSnapshotVersion: 2,
|
||
libraryIndexRevision: candidates.Revision,
|
||
policyVersion: "shared-policy");
|
||
|
||
Assert.Equal(candidate.LibraryTrackId, input.LibraryTrackId);
|
||
Assert.Equal(TrackMatchState.Accepted, input.State);
|
||
Assert.Equal(decision.AcceptThreshold, input.Threshold);
|
||
Assert.Equal(TrackMatchDecisionEngine.AlgorithmVersion, input.MatcherVersion);
|
||
Assert.Equal(decision.Candidates.Count,
|
||
JsonSerializer.Deserialize<TrackMatchCandidateScore[]>(input.CandidateResultsJson)!.Length);
|
||
}
|
||
|
||
[Fact]
|
||
public void ReviewScoresUseTheMatcherAndReturnHighestConfidenceFirst()
|
||
{
|
||
var scope = Scope();
|
||
var exact = Candidate(scope);
|
||
var weak = Candidate(scope) with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "weak",
|
||
Title = "Different",
|
||
Artist = "Someone Else"
|
||
};
|
||
|
||
var scores = new TrackMatchDecisionEngine().ScoreCandidates(Source(), [weak, exact]);
|
||
|
||
Assert.Equal(exact.LibraryTrackId, scores[0].LibraryTrackId);
|
||
Assert.True(scores[0].Confidence > scores[1].Confidence);
|
||
}
|
||
|
||
[Fact]
|
||
public void LibraryIndexRevision_IsStableAndChangesWithMatchableMetadata()
|
||
{
|
||
var scope = Scope();
|
||
var candidate = Candidate(scope);
|
||
|
||
var first = TrackMatchDecisionEngine.LibraryIndexRevision([candidate]);
|
||
var reordered = TrackMatchDecisionEngine.LibraryIndexRevision([candidate]);
|
||
var changed = TrackMatchDecisionEngine.LibraryIndexRevision(
|
||
[candidate with { Title = "Changed" }]);
|
||
|
||
Assert.Equal(first, reordered);
|
||
Assert.NotEqual(first, changed);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("USRC17607839", null, "US-RC1-76-07839", null, "isrc_exact")]
|
||
[InlineData(null, "f4adcc1d-32e6-4f80-b9d5-abc1c21f61c8", null, "F4ADCC1D-32E6-4F80-B9D5-ABC1C21F61C8", "musicbrainz_recording_id_exact")]
|
||
public void ExactIdentitySignals_AreAcceptedWithAnExplanation(
|
||
string? sourceIsrc,
|
||
string? sourceMbid,
|
||
string? candidateIsrc,
|
||
string? candidateMbid,
|
||
string expectedReason)
|
||
{
|
||
var scope = Scope();
|
||
var candidate = Candidate(scope) with
|
||
{
|
||
Isrc = candidateIsrc,
|
||
MusicBrainzRecordingId = candidateMbid
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope,
|
||
Source() with { Isrc = sourceIsrc, MusicBrainzRecordingId = sourceMbid },
|
||
[candidate]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(candidate.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||
Assert.Contains(expectedReason, decision.Reasons);
|
||
Assert.Equal(1, decision.SourceSnapshotVersion);
|
||
}
|
||
|
||
[Fact]
|
||
public void VerifiedProviderIdentity_IsAccepted()
|
||
{
|
||
var scope = Scope();
|
||
var candidate = Candidate(scope) with
|
||
{
|
||
ProviderTrackIds = new Dictionary<string, string>
|
||
{
|
||
["spotify"] = "source-track"
|
||
}
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope, Source(), [candidate]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Contains("provider_track_id_exact", decision.Reasons);
|
||
}
|
||
|
||
[Fact]
|
||
public void FuzzyCorpus_AcceptsStrongMatchAndLeavesWeakMatchUnresolved()
|
||
{
|
||
var scope = Scope();
|
||
var source = Source();
|
||
var strong = Candidate(scope) with
|
||
{
|
||
Title = "A Song (Remastered)",
|
||
Artist = "The Artist",
|
||
Album = "The Album",
|
||
DurationMilliseconds = 241_000
|
||
};
|
||
var weak = Candidate(scope) with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "weak",
|
||
Title = "Completely Different",
|
||
Artist = "Someone Else",
|
||
Album = "Another Album",
|
||
DurationMilliseconds = 90_000
|
||
};
|
||
var engine = new TrackMatchDecisionEngine();
|
||
|
||
var accepted = engine.Decide(scope, source, [strong, weak]);
|
||
var unresolved = engine.Decide(scope, source, [weak]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, accepted.State);
|
||
Assert.Equal(strong.LibraryTrackId, accepted.SelectedLibraryTrackId);
|
||
Assert.Contains("title_exact", accepted.Reasons);
|
||
var diagnostics = accepted.Candidates[0];
|
||
Assert.Equal("A Song (Remastered)", diagnostics.Title);
|
||
Assert.Equal("a song", diagnostics.NormalizedCandidateTitle);
|
||
Assert.Equal(1, diagnostics.ArtistOverlap);
|
||
Assert.Equal(1_000, diagnostics.DurationDeltaMilliseconds);
|
||
Assert.NotNull(diagnostics.Components);
|
||
Assert.Equal(TrackMatchReviewState.Unresolved, unresolved.State);
|
||
Assert.Null(unresolved.SelectedLibraryTrackId);
|
||
Assert.Contains("below_suggestion_threshold", unresolved.Warnings);
|
||
}
|
||
|
||
[Fact]
|
||
public void NearTiedCandidates_RemainAmbiguousWithoutAutomaticAction()
|
||
{
|
||
var scope = Scope();
|
||
var first = Candidate(scope);
|
||
var second = first with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "local-2",
|
||
CanonicalRecordingId = Guid.CreateVersion7(),
|
||
DurationMilliseconds = 240_000
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, Source(), [first, second]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Ambiguous, decision.State);
|
||
Assert.Null(decision.SelectedLibraryTrackId);
|
||
Assert.Contains("ambiguous_top_candidates", decision.Warnings);
|
||
}
|
||
|
||
[Fact]
|
||
public void HigherScoringCandidateOutsideNarrowMarginWins()
|
||
{
|
||
var scope = Scope();
|
||
var first = Candidate(scope);
|
||
var second = first with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "local-2",
|
||
CanonicalRecordingId = Guid.CreateVersion7(),
|
||
DurationMilliseconds = 242_000
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, Source(), [first, second]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(first.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||
}
|
||
|
||
[Fact]
|
||
public void DurationEvidenceBreaksOtherwiseExactLongFormTie()
|
||
{
|
||
var scope = Scope();
|
||
var closer = Candidate(scope) with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "closer",
|
||
CanonicalRecordingId = Guid.CreateVersion7(),
|
||
DurationMilliseconds = 327_000,
|
||
IsLocal = false
|
||
};
|
||
var farther = closer with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "farther",
|
||
CanonicalRecordingId = Guid.CreateVersion7(),
|
||
DurationMilliseconds = 405_000
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope,
|
||
Source() with { DurationMilliseconds = 338_000 },
|
||
[farther, closer]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(closer.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||
Assert.True(decision.Candidates[0].Confidence > decision.Candidates[1].Confidence);
|
||
}
|
||
|
||
[Fact]
|
||
public void StableRecordingEvidenceCanCollapseDuplicateInternalCanonicalIds()
|
||
{
|
||
var scope = Scope();
|
||
var first = Candidate(scope) with
|
||
{
|
||
CanonicalRecordingId = Guid.CreateVersion7(),
|
||
MusicBrainzRecordingId = "571c58f1-fba5-4e02-94c9-b54ff0e2d52f",
|
||
DurationMilliseconds = 239_000
|
||
};
|
||
var second = first with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "duplicate",
|
||
CanonicalRecordingId = Guid.CreateVersion7(),
|
||
MusicBrainzRecordingId = null,
|
||
DurationMilliseconds = 241_000
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope, Source(), [first, second]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Contains(decision.SelectedLibraryTrackId, new Guid?[]
|
||
{
|
||
first.LibraryTrackId,
|
||
second.LibraryTrackId
|
||
});
|
||
}
|
||
|
||
[Fact]
|
||
public void DuplicateCopiesWithoutStrongIdentityDoNotBlockBestMatch()
|
||
{
|
||
var scope = Scope();
|
||
var first = Candidate(scope);
|
||
var second = first with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "local-2",
|
||
CanonicalRecordingId = null,
|
||
DurationMilliseconds = 241_000
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, Source(), [first, second]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(first.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||
}
|
||
|
||
[Fact]
|
||
public void SameMusicBrainzRecordingCandidates_DoNotTriggerAmbiguity()
|
||
{
|
||
var scope = Scope();
|
||
var first = Candidate(scope) with
|
||
{
|
||
CanonicalRecordingId = null,
|
||
MusicBrainzRecordingId = "c37ae419-a9a1-4a89-8c3f-d9cadceb8d7f",
|
||
ProviderTrackIds = new Dictionary<string, string>
|
||
{
|
||
["musicbrainzrecording"] = "16ba7915-2acf-42b2-8c87-ed67090dca91"
|
||
}
|
||
};
|
||
var second = first with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "local-2",
|
||
MusicBrainzRecordingId = "2b9f56d8-0a1a-4d84-bcc9-67d29641ba30",
|
||
ProviderTrackIds = new Dictionary<string, string>
|
||
{
|
||
["MusicBrainzRecording"] = "16BA79152ACF42B28C87ED67090DCA91"
|
||
}
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, Source(), [first, second]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Contains(decision.SelectedLibraryTrackId, new Guid?[]
|
||
{
|
||
first.LibraryTrackId,
|
||
second.LibraryTrackId
|
||
});
|
||
Assert.Equal(2, decision.Candidates.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void ClassicalCreditVariantsOnTheSameAlbum_DoNotTriggerAmbiguity()
|
||
{
|
||
var scope = Scope();
|
||
var source = Source() with
|
||
{
|
||
Title = "21 Hungarian Dances, WoO 1: Hungarian Dance No. 5 in G Minor. Allegro (Orch. Schmeling)",
|
||
Artist = "Claudio Abbado, Wiener Philharmoniker",
|
||
Album = "Brahms: 21 Hungarian Dances",
|
||
DurationMilliseconds = 138_706
|
||
};
|
||
var conductor = Candidate(scope) with
|
||
{
|
||
CanonicalRecordingId = null,
|
||
Title = $"Brahms: {source.Title}",
|
||
Artist = "Claudio Abbado",
|
||
Album = source.Album,
|
||
DurationMilliseconds = 139_000,
|
||
IsLocal = false
|
||
};
|
||
var orchestra = conductor with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "deezer-4158796",
|
||
Title = $"21 Hungarian Dances, WoO 1 : Brahms: {source.Title}",
|
||
Artist = "Wiener Philharmoniker",
|
||
DurationMilliseconds = 137_000
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope, source, [conductor, orchestra]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(2, decision.Candidates.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void FullArtistCreditsAndAlbumBreakCompilationTie()
|
||
{
|
||
var scope = Scope();
|
||
var source = Source() with
|
||
{
|
||
Title = "Hit 'Em Up - Single Version",
|
||
Artist = "2Pac, Outlawz",
|
||
Album = "Greatest Hits",
|
||
AlbumArtist = null,
|
||
DurationMilliseconds = 313_000
|
||
};
|
||
var preferred = Candidate(scope) with
|
||
{
|
||
Title = "Hit ’Em Up",
|
||
Artist = "2Pac, The Outlawz",
|
||
Album = "Greatest Hits",
|
||
AlbumArtist = "2Pac",
|
||
DurationMilliseconds = 313_000
|
||
};
|
||
var compilation = preferred with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "compilation",
|
||
Artist = "2Pac",
|
||
Album = "Death Row: Greatest Hits",
|
||
AlbumArtist = "Various Artists"
|
||
};
|
||
var candidates = new TrackMatchCandidateIndex([preferred, compilation]).Select(source);
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, source, candidates);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(preferred.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||
Assert.True(decision.Candidates[0].Confidence - decision.Candidates[1].Confidence > decision.AmbiguityDelta);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("PiLlOwT4lK")]
|
||
[InlineData("P1ll0wtalk")]
|
||
public void PillowtalkLookalikeTitle_IsRetrievedAndAccepted(string jellyfinTitle)
|
||
{
|
||
var scope = Scope();
|
||
var source = Source() with
|
||
{
|
||
Title = "PILLOWTALK",
|
||
Artist = "ZAYN",
|
||
Album = "Mind of Mine",
|
||
AlbumArtist = "ZAYN",
|
||
DurationMilliseconds = 203_000
|
||
};
|
||
var candidate = Candidate(scope) with
|
||
{
|
||
Title = jellyfinTitle,
|
||
Artist = "ZAYN",
|
||
Album = "Mind of Mine",
|
||
AlbumArtist = "ZAYN",
|
||
DurationMilliseconds = 203_000
|
||
};
|
||
|
||
var selected = new TrackMatchCandidateIndex([candidate]).Select(source);
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, source, selected);
|
||
|
||
Assert.Same(candidate, Assert.Single(selected));
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(1, Assert.Single(decision.Candidates).Components!["title"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Exact_title_primary_artist_and_duration_accept_single_album_variants()
|
||
{
|
||
var scope = Scope();
|
||
var source = Source() with
|
||
{
|
||
Title = "rockstar",
|
||
Artist = "Post Malone, 21 Savage",
|
||
Album = "rockstar",
|
||
DurationMilliseconds = 218_320
|
||
};
|
||
var candidate = Candidate(scope) with
|
||
{
|
||
Title = "rockstar",
|
||
Artist = "Post Malone",
|
||
Album = "beerbongs & bentleys",
|
||
DurationMilliseconds = 218_146
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, source, [candidate]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(candidate.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||
}
|
||
|
||
[Fact]
|
||
public void Missing_optional_metadata_is_neutral_and_not_reported_as_zero()
|
||
{
|
||
var scope = Scope();
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope,
|
||
Source() with
|
||
{
|
||
Album = "You'll Be Alright, Kid (Chapter 1)",
|
||
AlbumArtist = null,
|
||
DurationMilliseconds = null
|
||
},
|
||
[Candidate(scope) with
|
||
{
|
||
Album = null,
|
||
AlbumArtist = null,
|
||
DurationMilliseconds = null
|
||
}]);
|
||
|
||
var score = Assert.Single(decision.Candidates);
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(1, score.Confidence);
|
||
Assert.Contains("artist", score.Components!.Keys);
|
||
Assert.Contains("title", score.Components.Keys);
|
||
Assert.DoesNotContain("album", score.Components.Keys);
|
||
Assert.DoesNotContain("albumArtist", score.Components.Keys);
|
||
Assert.DoesNotContain("duration", score.Components.Keys);
|
||
Assert.Null(score.AlbumEvidence);
|
||
}
|
||
|
||
[Fact]
|
||
public void Album_mismatch_cannot_reduce_exact_core_identity()
|
||
{
|
||
var scope = Scope();
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope,
|
||
Source() with
|
||
{
|
||
Album = "You'll Be Alright, Kid (Chapter 1)",
|
||
AlbumArtist = null,
|
||
DurationMilliseconds = 186_964
|
||
},
|
||
[Candidate(scope) with
|
||
{
|
||
Album = "Ordinary",
|
||
AlbumArtist = "Alex Warren",
|
||
DurationMilliseconds = 186_964
|
||
}]);
|
||
|
||
var score = Assert.Single(decision.Candidates);
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(1, score.Confidence);
|
||
Assert.Contains("album", score.Components!.Keys);
|
||
Assert.DoesNotContain("albumArtist", score.Components.Keys);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("A Song (Live)", "A Song")]
|
||
[InlineData("A Song", "A Song (Acoustic)")]
|
||
[InlineData("A Song (Remix)", "A Song")]
|
||
[InlineData("A Song (Instrumental)", "A Song")]
|
||
[InlineData("A Song", "A Song (Stripped)")]
|
||
[InlineData("A Song (Clean)", "A Song (Explicit)")]
|
||
public void SemanticVersionsRemainNegativeEvidence(string sourceTitle, string candidateTitle)
|
||
{
|
||
var scope = Scope();
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope,
|
||
Source() with { Title = sourceTitle },
|
||
[Candidate(scope) with { Title = candidateTitle }]);
|
||
|
||
Assert.NotEqual(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Contains("semantic_version_mismatch", Assert.Single(decision.Candidates).Warnings);
|
||
}
|
||
|
||
[Fact]
|
||
public void CandidateIndexNormalizesUnicodeCreditsAndReleaseDecorators()
|
||
{
|
||
var scope = Scope();
|
||
var candidate = Candidate(scope) with
|
||
{
|
||
Title = "Beyonce's Song",
|
||
Artist = "Artist, Guest"
|
||
};
|
||
var source = Source() with
|
||
{
|
||
Title = "Beyoncé’s Song - 2004 Remaster",
|
||
Artist = "Ártist feat. Guest"
|
||
};
|
||
|
||
var selected = new TrackMatchCandidateIndex([candidate]).Select(source);
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, source, selected);
|
||
|
||
Assert.Same(candidate, Assert.Single(selected));
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
}
|
||
|
||
[Fact]
|
||
public void CandidateIndexFindsDecoratedSpotifyTitleInLocalJellyfinLibrary()
|
||
{
|
||
var scope = Scope();
|
||
var source = Source() with
|
||
{
|
||
Title = "Link Up (Metro Boomin & Don Toliver, Wizkid feat. BEAM & Toian) - Spider-Verse Remix (Spider-Man: Across the Spider-Verse )",
|
||
Artist = "Metro Boomin, Don Toliver, Wizkid, BEAM, Toian",
|
||
Album = "METRO BOOMIN PRESENTS SPIDER-MAN: ACROSS THE SPIDER-VERSE (SOUNDTRACK FROM AND INSPIRED BY THE MOTION PICTURE)",
|
||
DurationMilliseconds = 195_386
|
||
};
|
||
var local = Candidate(scope) with
|
||
{
|
||
Title = "Link Up (Spider-Verse remix)",
|
||
Artist = "Metro Boomin, Don Toliver, Wizkid, BEAM, Toian",
|
||
Album = "METRO BOOMIN PRESENTS SPIDER‐MAN: ACROSS THE SPIDER‐VERSE: SOUNDTRACK FROM AND INSPIRED BY THE MOTION PICTURE",
|
||
AlbumArtist = "Metro Boomin",
|
||
DurationMilliseconds = 195_386,
|
||
Isrc = null,
|
||
CanonicalRecordingId = null
|
||
};
|
||
|
||
var selected = new TrackMatchCandidateIndex([local]).Select(source);
|
||
var score = Assert.Single(new TrackMatchDecisionEngine().ScoreCandidates(source, selected));
|
||
|
||
Assert.Equal(local.LibraryTrackId, score.LibraryTrackId);
|
||
Assert.True(score.Confidence >= 0.9);
|
||
Assert.Equal(0.07, score.Components!["localPreference"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void LocalPreferenceBoostCountsTowardAutomaticAcceptance()
|
||
{
|
||
var scope = Scope();
|
||
var source = Source() with
|
||
{
|
||
Title = "Silk & Cologne (EI8HT & Offset) - Spider-Verse Remix",
|
||
Artist = "EI8HT, Offset"
|
||
};
|
||
var candidate = Candidate(scope) with
|
||
{
|
||
Title = "Silk and Cologne (Spider-Verse remix)",
|
||
Artist = "Ei8ht, OFFSET"
|
||
};
|
||
var engine = new TrackMatchDecisionEngine();
|
||
|
||
var score = Assert.Single(engine.ScoreCandidates(source, [candidate]));
|
||
var decision = engine.Decide(scope, source, [candidate]);
|
||
|
||
Assert.True(score.Confidence < decision.AcceptThreshold);
|
||
Assert.True(score.Components!["preferenceScore"] >= decision.AcceptThreshold);
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(score.Components["preferenceScore"], decision.Confidence);
|
||
}
|
||
|
||
[Fact]
|
||
public void AcceptanceQualifiedLocalCandidateWinsOverHigherProviderRoute()
|
||
{
|
||
var scope = Scope();
|
||
var source = Source() with { Isrc = "USAAA2600001" };
|
||
var local = Candidate(scope) with
|
||
{
|
||
CanonicalRecordingId = null,
|
||
Album = null,
|
||
AlbumArtist = null,
|
||
DurationMilliseconds = 230_000,
|
||
IsLocal = true
|
||
};
|
||
var provider = Candidate(scope) with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "provider-route",
|
||
CanonicalRecordingId = null,
|
||
Isrc = source.Isrc,
|
||
IsLocal = false,
|
||
ProviderOrigin = ProviderOrigin.BuiltIn
|
||
};
|
||
var engine = new TrackMatchDecisionEngine();
|
||
|
||
var scores = engine.ScoreCandidates(source, [provider, local]);
|
||
var localScore = scores.Single(score => score.LibraryTrackId == local.LibraryTrackId);
|
||
var providerScore = scores.Single(score => score.LibraryTrackId == provider.LibraryTrackId);
|
||
var decision = engine.Decide(scope, source, [provider, local]);
|
||
|
||
Assert.True(providerScore.Confidence > localScore.Components!["preferenceScore"]);
|
||
Assert.True(localScore.Components["preferenceScore"] >= decision.AcceptThreshold);
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(local.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||
Assert.Equal(local.LibraryTrackId, decision.Candidates[0].LibraryTrackId);
|
||
Assert.Equal(localScore.Components["preferenceScore"], decision.Confidence);
|
||
}
|
||
|
||
[Fact]
|
||
public void AcceptanceQualifiedLocalCandidateIsRetainedAheadOfTwentyProviderRoutes()
|
||
{
|
||
var scope = Scope();
|
||
var source = Source() with { Isrc = "USAAA2600002" };
|
||
var local = Candidate(scope) with
|
||
{
|
||
CanonicalRecordingId = null,
|
||
Album = null,
|
||
AlbumArtist = null,
|
||
DurationMilliseconds = 230_000
|
||
};
|
||
var providers = Enumerable.Range(0, 20).Select(index => Candidate(scope) with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = $"provider-{index}",
|
||
CanonicalRecordingId = null,
|
||
Isrc = source.Isrc,
|
||
IsLocal = false,
|
||
ProviderOrigin = ProviderOrigin.BuiltIn
|
||
});
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope, source, [.. providers, local]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(local.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||
Assert.Equal(local.LibraryTrackId, decision.Candidates[0].LibraryTrackId);
|
||
Assert.Equal(20, decision.Candidates.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void MultipleAcceptanceQualifiedLocalRecordingsRemainAmbiguous()
|
||
{
|
||
var scope = Scope();
|
||
var first = Candidate(scope);
|
||
var second = first with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "local-2",
|
||
CanonicalRecordingId = Guid.CreateVersion7(),
|
||
DurationMilliseconds = 240_000
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, Source(), [first, second]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Ambiguous, decision.State);
|
||
Assert.Null(decision.SelectedLibraryTrackId);
|
||
Assert.Contains("ambiguous_top_candidates", decision.Warnings);
|
||
}
|
||
|
||
[Fact]
|
||
public void ExtensionPreferencePenaltyFavorsAnOtherwiseEqualBuiltInCandidate()
|
||
{
|
||
var scope = Scope();
|
||
var builtIn = Candidate(scope) with
|
||
{
|
||
IsLocal = false,
|
||
ProviderOrigin = ProviderOrigin.BuiltIn
|
||
};
|
||
var extension = builtIn with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "extension-track",
|
||
ProviderOrigin = ProviderOrigin.Extension
|
||
};
|
||
|
||
var scores = new TrackMatchDecisionEngine().ScoreCandidates(Source(), [extension, builtIn]);
|
||
var extensionComponents = scores[1].Components!;
|
||
|
||
Assert.Equal(builtIn.LibraryTrackId, scores[0].LibraryTrackId);
|
||
Assert.Equal(-0.03, extensionComponents["extensionPenalty"]);
|
||
Assert.Equal(scores[1].Confidence - 0.03, extensionComponents["preferenceScore"], 6);
|
||
}
|
||
|
||
[Fact]
|
||
public void ScopedManualPinWinsButCannotCrossTenantOrInvisibleLibrary()
|
||
{
|
||
var scope = Scope();
|
||
var visible = Candidate(scope);
|
||
var pin = new ScopedTrackMatchOverride(
|
||
scope.TenantId,
|
||
scope.UserId,
|
||
scope.LibraryScopeId,
|
||
"spotify",
|
||
"source-track",
|
||
visible.LibraryTrackId);
|
||
|
||
var pinned = new TrackMatchDecisionEngine().Decide(scope, Source(), [visible], pin);
|
||
var foreignPin = pin with { TenantId = Guid.CreateVersion7() };
|
||
|
||
Assert.Equal(TrackMatchReviewState.Pinned, pinned.State);
|
||
Assert.Equal(visible.LibraryTrackId, pinned.SelectedLibraryTrackId);
|
||
Assert.Throws<UnauthorizedAccessException>(() =>
|
||
new TrackMatchDecisionEngine().Decide(scope, Source(), [visible], foreignPin));
|
||
}
|
||
|
||
[Fact]
|
||
public void ManualRejectionPersistsAsAReviewStateAndCannotTriggerSelection()
|
||
{
|
||
var scope = Scope();
|
||
var visible = Candidate(scope);
|
||
var rejection = new ScopedTrackMatchOverride(
|
||
scope.TenantId,
|
||
scope.UserId,
|
||
scope.LibraryScopeId,
|
||
"spotify",
|
||
"source-track",
|
||
PinnedLibraryTrackId: null,
|
||
RejectedLibraryTrackIds: new HashSet<Guid> { visible.LibraryTrackId });
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, Source(), [visible], rejection);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Rejected, decision.State);
|
||
Assert.Null(decision.SelectedLibraryTrackId);
|
||
Assert.Contains("manual_override_rejected_all", decision.Warnings);
|
||
}
|
||
|
||
[Fact]
|
||
public void CandidateRejection_SelectsTheNextMatchAndExpiresWithMatcherVersion()
|
||
{
|
||
var scope = Scope();
|
||
var rejected = Candidate(scope) with
|
||
{
|
||
ProviderTrackIds = new Dictionary<string, string> { ["spotify"] = "source-track" }
|
||
};
|
||
var alternate = Candidate(scope) with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "alternate"
|
||
};
|
||
var manual = new ScopedTrackMatchOverride(
|
||
scope.TenantId,
|
||
scope.UserId,
|
||
scope.LibraryScopeId,
|
||
"spotify",
|
||
"source-track",
|
||
null,
|
||
new HashSet<Guid> { rejected.LibraryTrackId });
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope, Source(), [rejected, alternate], manual);
|
||
var record = new ManualTrackOverrideRecord
|
||
{
|
||
Decision = ManualOverrideDecision.Reject,
|
||
LibraryTrackId = rejected.LibraryTrackId,
|
||
MatcherVersion = "retired"
|
||
};
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(alternate.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||
Assert.False(TrackMatchOverridePolicy.IsEffectiveRejection(record, null));
|
||
}
|
||
|
||
[Fact]
|
||
public void CandidateVisibility_ExcludesOtherUsersLibrariesBackendsAndTenants()
|
||
{
|
||
var scope = Scope();
|
||
var candidates = new[]
|
||
{
|
||
Candidate(scope) with { TenantId = Guid.CreateVersion7() },
|
||
Candidate(scope) with { OwnerUserId = Guid.CreateVersion7() },
|
||
Candidate(scope) with { LibraryScopeId = "other-library" },
|
||
Candidate(scope) with { BackendInstanceId = "other-backend" }
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, Source(), candidates);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Unresolved, decision.State);
|
||
Assert.Empty(decision.Candidates);
|
||
Assert.Contains("no_indexed_candidate", decision.Warnings);
|
||
}
|
||
|
||
[Fact]
|
||
public void ExactProviderIdentity_MatchesOneRecordingAcrossProviderGraph()
|
||
{
|
||
var scope = Scope();
|
||
var candidate = Candidate(scope) with
|
||
{
|
||
ProviderTrackIds = new Dictionary<string, string>
|
||
{
|
||
["spotify"] = "source-track",
|
||
["deezer"] = "target-track",
|
||
["apple-musickit"] = "apple-track"
|
||
}
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, Source(), [candidate]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Contains("provider_track_id_exact", decision.Reasons);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(
|
||
"Heebiejeebies - Bonus",
|
||
"Aminé, Kehlani",
|
||
"Heebiejeebies",
|
||
"Aminé, Kehlani")]
|
||
[InlineData(
|
||
"Homemade Dynamite (Feat. Khalid, Post Malone & SZA) - REMIX",
|
||
"Lorde, Khalid, Post Malone & SZA",
|
||
"Homemade Dynamite (REMIX)",
|
||
"Lorde, Khalid, Post Malone, SZA")]
|
||
public void ReportedDecoratorFailures_AcceptAndExposeComponentScores(
|
||
string sourceTitle,
|
||
string sourceArtist,
|
||
string candidateTitle,
|
||
string candidateArtist)
|
||
{
|
||
var scope = Scope();
|
||
var source = Source() with
|
||
{
|
||
Title = sourceTitle,
|
||
Artist = sourceArtist,
|
||
Album = "Reported fixture",
|
||
AlbumArtist = sourceArtist
|
||
};
|
||
var candidate = Candidate(scope) with
|
||
{
|
||
Title = candidateTitle,
|
||
Artist = candidateArtist,
|
||
Album = "Reported fixture",
|
||
AlbumArtist = candidateArtist
|
||
};
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, source, [candidate]);
|
||
var score = Assert.Single(decision.Candidates);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.False(decision.RequiresReview);
|
||
Assert.True(score.Confidence >= decision.AcceptThreshold);
|
||
Assert.NotNull(score.Components);
|
||
Assert.Equal(1, score.Components["title"]);
|
||
Assert.True(score.Components["artist"] >= 0.85);
|
||
Assert.Contains("title_exact", score.Reasons);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("Lorde feat. Khalid & SZA", "SZA, Lorde, Khalid")]
|
||
[InlineData("Lorde featuring Khalid, SZA", "Lorde & Khalid & SZA")]
|
||
[InlineData("Lorde ft Khalid with SZA", "Khalid, SZA, Lorde")]
|
||
public void EquivalentArtistCreditSyntax_IsRankedAsTheSameArtistSet(
|
||
string sourceArtist,
|
||
string candidateArtist)
|
||
{
|
||
var scope = Scope();
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope,
|
||
Source() with { Artist = sourceArtist, AlbumArtist = sourceArtist },
|
||
[Candidate(scope) with { Artist = candidateArtist, AlbumArtist = candidateArtist }]);
|
||
|
||
var score = Assert.Single(decision.Candidates);
|
||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||
Assert.Equal(1, score.Components!["artist"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void ConflictingFeaturedArtist_IsNotAutomaticallyAccepted()
|
||
{
|
||
var scope = Scope();
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope,
|
||
Source() with
|
||
{
|
||
Artist = "Lorde feat. SZA",
|
||
AlbumArtist = "Lorde"
|
||
},
|
||
[Candidate(scope) with
|
||
{
|
||
Artist = "Lorde feat. Khalid",
|
||
AlbumArtist = "Lorde"
|
||
}]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Unresolved, decision.State);
|
||
Assert.True(Assert.Single(decision.Candidates).Components!["artist"] < 0.7);
|
||
Assert.Contains("weak_artist_evidence_review", decision.Warnings);
|
||
}
|
||
|
||
[Fact]
|
||
public void WeakCandidate_ExposesThresholdAndReviewReason()
|
||
{
|
||
var scope = Scope();
|
||
var decision = new TrackMatchDecisionEngine().Decide(
|
||
scope,
|
||
Source(),
|
||
[Candidate(scope) with { Title = "Wrong", Artist = "Unknown", Album = "Elsewhere", DurationMilliseconds = 12_000 }]);
|
||
|
||
Assert.Equal(TrackMatchReviewState.Unresolved, decision.State);
|
||
Assert.True(decision.RequiresReview);
|
||
Assert.Equal(0.88, decision.AcceptThreshold);
|
||
Assert.Equal(0.72, decision.SuggestThreshold);
|
||
Assert.Contains("below_suggestion_threshold", decision.Warnings);
|
||
}
|
||
|
||
[Fact]
|
||
public void Local_candidates_receive_the_default_seven_point_preference()
|
||
{
|
||
var scope = Scope();
|
||
var external = Candidate(scope) with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
Title = "A Songs",
|
||
DurationMilliseconds = null,
|
||
Album = null,
|
||
AlbumArtist = null,
|
||
IsLocal = false
|
||
};
|
||
var local = external with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = "local-preferred",
|
||
IsLocal = true
|
||
};
|
||
|
||
var scores = new TrackMatchDecisionEngine()
|
||
.ScoreCandidates(Source(), [external, local])
|
||
.ToDictionary(item => item.LibraryTrackId);
|
||
|
||
Assert.Equal(
|
||
scores[external.LibraryTrackId].Confidence,
|
||
scores[local.LibraryTrackId].Confidence);
|
||
Assert.Equal(0.07, scores[local.LibraryTrackId].Components!["localPreference"]);
|
||
Assert.Equal(
|
||
Math.Min(1, scores[local.LibraryTrackId].Confidence + 0.07),
|
||
scores[local.LibraryTrackId].Components!["preferenceScore"]);
|
||
Assert.DoesNotContain(
|
||
"localPreference",
|
||
scores[external.LibraryTrackId].Components?.Keys ?? []);
|
||
}
|
||
|
||
[Fact]
|
||
public void LargeLibrary_RetainsOnlyTopReviewCandidates()
|
||
{
|
||
var scope = Scope();
|
||
var candidates = Enumerable.Range(0, 25)
|
||
.Select(index => Candidate(scope) with
|
||
{
|
||
LibraryTrackId = Guid.CreateVersion7(),
|
||
BackendItemId = $"local-{index}",
|
||
Title = $"Candidate {index}"
|
||
})
|
||
.ToArray();
|
||
|
||
var decision = new TrackMatchDecisionEngine().Decide(scope, Source(), candidates);
|
||
|
||
Assert.Equal(20, decision.Candidates.Count);
|
||
}
|
||
|
||
private static TrackMatchScope Scope() => new(
|
||
Guid.CreateVersion7(),
|
||
Guid.CreateVersion7(),
|
||
"backend",
|
||
"music",
|
||
Guid.CreateVersion7(),
|
||
PolicyVersion: 3,
|
||
SourceSnapshotVersion: 1);
|
||
|
||
private static ExternalTrackMatchSnapshot Source() => new(
|
||
"snapshot-1",
|
||
"spotify",
|
||
"source-track",
|
||
"A Song",
|
||
"The Artist",
|
||
"The Album",
|
||
"The Artist",
|
||
240_000,
|
||
null,
|
||
null,
|
||
IsExplicit: false);
|
||
|
||
private static LocalTrackMatchCandidate Candidate(TrackMatchScope scope) => new(
|
||
Guid.CreateVersion7(),
|
||
scope.TenantId,
|
||
scope.UserId,
|
||
scope.BackendInstanceId,
|
||
scope.LibraryScopeId,
|
||
"local-1",
|
||
Guid.CreateVersion7(),
|
||
"A Song",
|
||
"The Artist",
|
||
"The Album",
|
||
"The Artist",
|
||
240_000,
|
||
null,
|
||
null,
|
||
IsExplicit: false);
|
||
}
|