mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 21:55:39 -04:00
405 lines
16 KiB
C#
405 lines
16 KiB
C#
using allstarr.Models.Domain;
|
|
using allstarr.Models.Search;
|
|
using allstarr.Models.Subsonic;
|
|
using allstarr.Services;
|
|
using allstarr.Services.Common;
|
|
using allstarr.Services.Spotify;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace allstarr.Tests;
|
|
|
|
public sealed class PerProviderTrackWalkerTests
|
|
{
|
|
[Fact]
|
|
public async Task LocalShortCircuit_ReturnsLocalMatchWithoutWalking()
|
|
{
|
|
var local = NewSong("local-1", "Never Gonna Give You Up", "Rick Astley", isLocal: true);
|
|
var walker = NewWalker();
|
|
var source = NewSource("Never Gonna Give You Up", "Rick Astley");
|
|
|
|
var result = await walker.WalkAsync(
|
|
source,
|
|
new[] { "deezer", "qobuz" },
|
|
localMatch: local,
|
|
localMatchScore: 95,
|
|
default);
|
|
|
|
Assert.Equal(local, result.MatchedSong);
|
|
Assert.Equal(PerProviderTrackMatcher.MatchTypeLocal, result.MatchType);
|
|
Assert.Equal("local", result.ProviderUsed);
|
|
Assert.Empty(result.Walked);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FirstProviderThatAccepts_StopsTheWalk()
|
|
{
|
|
var deezer = new DeezerFake(new[] { NewSong("ext-deezer-1", "Never Gonna Give You Up", "Rick Astley") });
|
|
var qobuz = new QobuzFake(new[] { NewSong("ext-qobuz-1", "Never Gonna Give You Up", "Rick Astley") });
|
|
var walker = NewWalker(deezer, qobuz);
|
|
var source = NewSource("Never Gonna Give You Up", "Rick Astley");
|
|
|
|
var result = await walker.WalkAsync(
|
|
source,
|
|
new[] { "deezer", "qobuz" },
|
|
localMatch: null,
|
|
localMatchScore: null,
|
|
default);
|
|
|
|
Assert.NotNull(result.MatchedSong);
|
|
Assert.Equal("deezer", result.ProviderUsed);
|
|
Assert.Equal(PerProviderTrackMatcher.MatchTypeProviderFuzzy, result.MatchType);
|
|
Assert.Single(result.Walked);
|
|
Assert.Equal("deezer", result.Walked[0].Provider);
|
|
Assert.Equal(PerProviderTrackMatcher.OutcomeAccepted, result.Walked[0].Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LowScoreProvider_AdvancesToNextProvider()
|
|
{
|
|
var deezer = new DeezerFake(new[] { NewSong("ext-deezer-1", "Completely Different Song", "Some Band") });
|
|
var qobuz = new QobuzFake(new[] { NewSong("ext-qobuz-1", "Never Gonna Give You Up", "Rick Astley") });
|
|
var walker = NewWalker(deezer, qobuz);
|
|
var source = NewSource("Never Gonna Give You Up", "Rick Astley");
|
|
|
|
var result = await walker.WalkAsync(
|
|
source,
|
|
new[] { "deezer", "qobuz" },
|
|
localMatch: null,
|
|
localMatchScore: null,
|
|
default);
|
|
|
|
Assert.NotNull(result.MatchedSong);
|
|
Assert.Equal("qobuz", result.ProviderUsed);
|
|
Assert.Equal(2, result.Walked.Count);
|
|
Assert.Equal(PerProviderTrackMatcher.OutcomeLowScore, result.Walked[0].Outcome);
|
|
Assert.Equal(PerProviderTrackMatcher.OutcomeAccepted, result.Walked[1].Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsrcHit_OnTheConfiguredProvider_IsAcceptedFirst()
|
|
{
|
|
var isrcSong = NewSong("ext-deezer-123", "Never Gonna Give You Up", "Rick Astley");
|
|
isrcSong.Isrc = "GBANE0100001";
|
|
var deezer = new DeezerFake(Array.Empty<Song>(), isrcSong);
|
|
var walker = NewWalker(deezer);
|
|
var source = NewSource("Never Gonna Give You Up", "Rick Astley", isrc: "GBANE0100001");
|
|
|
|
var result = await walker.WalkAsync(
|
|
source,
|
|
new[] { "deezer" },
|
|
localMatch: null,
|
|
localMatchScore: null,
|
|
default);
|
|
|
|
Assert.Equal(isrcSong, result.MatchedSong);
|
|
Assert.Equal("deezer", result.ProviderUsed);
|
|
Assert.Equal(PerProviderTrackMatcher.MatchTypeIsrc, result.MatchType);
|
|
Assert.Equal(PerProviderTrackMatcher.OutcomeAccepted, result.Walked[0].Outcome);
|
|
Assert.StartsWith("isrc:", result.Walked[0].Query);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TitleOnlyRetry_OnFirstProvider_CanFindAfterInitialLowScore()
|
|
{
|
|
var deezer = new DeezerFake(
|
|
fuzzyResults: new[] { NewSong("ext-deezer-1", "Lost In Translation", "Other Artist") },
|
|
titleOnlyResults: new[] { NewSong("ext-deezer-2", "Africa", "Toto") },
|
|
titleOnlySentinel: "Toto");
|
|
var walker = NewWalker(deezer);
|
|
var source = NewSource("Africa", "Toto");
|
|
|
|
var result = await walker.WalkAsync(
|
|
source,
|
|
new[] { "deezer" },
|
|
localMatch: null,
|
|
localMatchScore: null,
|
|
default);
|
|
|
|
var walkedSummary = string.Join(", ", result.Walked.Select(w => $"{w.Provider}={w.Outcome}:{w.ReasonCode}:score={w.TopScore}"));
|
|
Assert.NotNull(result.MatchedSong);
|
|
Assert.Equal(PerProviderTrackMatcher.MatchTypeTitleOnly, result.MatchType);
|
|
Assert.True(result.Walked.Count > 0, $"walked: {walkedSummary}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoMatch_RecordsEveryWalkedProvider()
|
|
{
|
|
var deezer = new DeezerFake(new[] { NewSong("ext-deezer-1", "Wrong Title", "Wrong Artist") });
|
|
var qobuz = new QobuzFake(Array.Empty<Song>());
|
|
var walker = NewWalker(deezer, qobuz);
|
|
var source = NewSource("Never Gonna Give You Up", "Rick Astley");
|
|
|
|
var result = await walker.WalkAsync(
|
|
source,
|
|
new[] { "deezer", "qobuz" },
|
|
localMatch: null,
|
|
localMatchScore: null,
|
|
default);
|
|
|
|
Assert.Null(result.MatchedSong);
|
|
Assert.Equal(4, result.Walked.Count);
|
|
Assert.Contains(result.Walked, attempt => attempt.Provider == "deezer"
|
|
&& attempt.Outcome == PerProviderTrackMatcher.OutcomeLowScore
|
|
&& attempt.ReasonCode != null
|
|
&& attempt.ReasonCode.Contains("title=", StringComparison.Ordinal)
|
|
&& attempt.ReasonCode.Contains("album=", StringComparison.Ordinal)
|
|
&& attempt.ReasonCode.Contains("duration=", StringComparison.Ordinal));
|
|
Assert.Contains(result.Walked, attempt => attempt.Provider == "qobuz"
|
|
&& attempt.Outcome == PerProviderTrackMatcher.OutcomeEmpty);
|
|
Assert.Equal(2, result.Walked.Count(attempt =>
|
|
attempt.Query == FuzzyMatcher.SearchQuery(source.Title)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AlbumAndDuration_DisambiguateOtherwiseEqualProviderCandidates()
|
|
{
|
|
var wrong = NewSong(
|
|
"ext-deezer-wrong",
|
|
"Ordinary",
|
|
"Alex Warren",
|
|
album: "Unrelated Compilation",
|
|
durationSeconds: 95);
|
|
var correct = NewSong(
|
|
"ext-deezer-correct",
|
|
"Ordinary",
|
|
"Alex Warren",
|
|
album: "You'll Be Alright, Kid",
|
|
durationSeconds: 187);
|
|
var walker = NewWalker(new DeezerFake(new[] { wrong, correct }));
|
|
var source = NewSource(
|
|
"Ordinary",
|
|
"Alex Warren",
|
|
album: "You'll Be Alright, Kid",
|
|
durationMs: 187000);
|
|
|
|
var result = await walker.WalkAsync(
|
|
source,
|
|
new[] { "deezer" },
|
|
localMatch: null,
|
|
localMatchScore: null,
|
|
default);
|
|
|
|
Assert.Equal(correct.Id, result.MatchedSong?.Id);
|
|
var attempt = Assert.Single(result.Walked);
|
|
Assert.Contains("album=100", attempt.ReasonCode, StringComparison.Ordinal);
|
|
Assert.Contains("duration=100.0", attempt.ReasonCode, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnknownProviderId_RecordsNoServiceAndContinues()
|
|
{
|
|
var deezer = new DeezerFake(new[] { NewSong("ext-deezer-1", "Never Gonna Give You Up", "Rick Astley") });
|
|
var walker = NewWalker(deezer);
|
|
var source = NewSource("Never Gonna Give You Up", "Rick Astley");
|
|
|
|
var result = await walker.WalkAsync(
|
|
source,
|
|
new[] { "mystery-provider", "deezer" },
|
|
localMatch: null,
|
|
localMatchScore: null,
|
|
default);
|
|
|
|
Assert.NotNull(result.MatchedSong);
|
|
Assert.Equal("deezer", result.ProviderUsed);
|
|
Assert.Equal(2, result.Walked.Count);
|
|
Assert.Equal(PerProviderTrackMatcher.OutcomeNoService, result.Walked[0].Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CollectAllProviderMatches_RetainsLocalAndAcceptsOneRoutePerProvider()
|
|
{
|
|
var local = NewSong("local-1", "Never Gonna Give You Up", "Rick Astley", isLocal: true);
|
|
var deezer = new DeezerFake(new[]
|
|
{
|
|
NewSong("ext-deezer-1", "Never Gonna Give You Up", "Rick Astley")
|
|
});
|
|
var qobuz = new QobuzFake(new[]
|
|
{
|
|
NewSong("ext-qobuz-1", "Never Gonna Give You Up", "Rick Astley")
|
|
});
|
|
var walker = NewWalker(deezer, qobuz);
|
|
|
|
var result = await walker.WalkAsync(
|
|
NewSource("Never Gonna Give You Up", "Rick Astley"),
|
|
new[] { "deezer", "qobuz" },
|
|
localMatch: local,
|
|
localMatchScore: 98,
|
|
default,
|
|
collectAllProviderMatches: true);
|
|
|
|
Assert.Equal(local, result.MatchedSong);
|
|
Assert.Equal("local", result.ProviderUsed);
|
|
Assert.Equal(2, result.AcceptedMatches.Count);
|
|
Assert.Equal(new[] { "deezer", "qobuz" }, result.AcceptedMatches.Select(match => match.Provider));
|
|
Assert.Equal(2, result.Walked.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CollectAllProviderMatches_IsolatesProviderFailureAndKeepsLaterRoute()
|
|
{
|
|
var amazon = new AmazonFake();
|
|
var deezer = new DeezerFake(new[]
|
|
{
|
|
NewSong("ext-deezer-1", "Never Gonna Give You Up", "Rick Astley")
|
|
});
|
|
var walker = NewWalker(amazon, deezer);
|
|
|
|
var result = await walker.WalkAsync(
|
|
NewSource("Never Gonna Give You Up", "Rick Astley"),
|
|
new[] { "amazon", "deezer" },
|
|
localMatch: null,
|
|
localMatchScore: null,
|
|
default,
|
|
collectAllProviderMatches: true);
|
|
|
|
Assert.Single(result.AcceptedMatches);
|
|
Assert.Equal("deezer", result.AcceptedMatches[0].Provider);
|
|
Assert.Contains(result.Walked, attempt =>
|
|
attempt.Provider == "amazon" &&
|
|
attempt.Outcome == PerProviderTrackMatcher.OutcomeError);
|
|
}
|
|
|
|
private static PerProviderTrackWalker NewWalker(params IConcreteMetadataService[] services) =>
|
|
new(services, new PerProviderAcceptThresholds(), NullLogger.Instance);
|
|
|
|
private static InjectedSourceTrack NewSource(
|
|
string title,
|
|
string primaryArtist,
|
|
string? isrc = null,
|
|
string? album = null,
|
|
int? durationMs = 213000) =>
|
|
new(
|
|
SourceId: "src-1",
|
|
SourceProvider: "spotify",
|
|
Title: title,
|
|
Artists: new List<string> { primaryArtist },
|
|
Isrc: isrc,
|
|
DurationMs: durationMs,
|
|
Album: album);
|
|
|
|
private static Song NewSong(
|
|
string id,
|
|
string title,
|
|
string artist,
|
|
bool isLocal = false,
|
|
string? album = null,
|
|
int? durationSeconds = null) =>
|
|
new()
|
|
{
|
|
Id = id,
|
|
Title = title,
|
|
Artist = artist,
|
|
Album = album ?? string.Empty,
|
|
Duration = durationSeconds,
|
|
IsLocal = isLocal,
|
|
ExternalProvider = isLocal ? null : "deezer",
|
|
ExternalId = isLocal ? null : id
|
|
};
|
|
|
|
private abstract class SearchFakeBase : IConcreteMetadataService
|
|
{
|
|
public abstract string ProviderId { get; }
|
|
|
|
protected abstract IReadOnlyList<Song> SelectResults(string query);
|
|
|
|
public virtual Task<Song?> FindSongByIsrcAsync(string isrc, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult<Song?>(null);
|
|
|
|
public Task<List<Song>> SearchSongsAsync(string query, int limit, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(SelectResults(query).Take(limit).ToList());
|
|
|
|
public Task<List<Album>> SearchAlbumsAsync(string query, int limit, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new List<Album>());
|
|
|
|
public Task<List<Artist>> SearchArtistsAsync(string query, int limit, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new List<Artist>());
|
|
|
|
public Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new SearchResult());
|
|
|
|
public Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult<Song?>(null);
|
|
|
|
public Task<Album?> GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult<Album?>(null);
|
|
|
|
public Task<Artist?> GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult<Artist?>(null);
|
|
|
|
public Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new List<Album>());
|
|
|
|
public Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new List<Song>());
|
|
|
|
public Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new List<ExternalPlaylist>());
|
|
|
|
public Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult<ExternalPlaylist?>(null);
|
|
|
|
public Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new List<Song>());
|
|
}
|
|
|
|
private sealed class DeezerFake : SearchFakeBase
|
|
{
|
|
public override string ProviderId => "deezer";
|
|
|
|
private readonly IReadOnlyList<Song> _fuzzyResults;
|
|
private readonly IReadOnlyList<Song> _titleOnlyResults;
|
|
private readonly Song? _isrcResult;
|
|
private readonly string? _titleOnlySentinel;
|
|
|
|
public DeezerFake(IReadOnlyList<Song> fuzzyResults, Song? isrcResult = null)
|
|
{
|
|
_fuzzyResults = fuzzyResults;
|
|
_titleOnlyResults = fuzzyResults;
|
|
_isrcResult = isrcResult;
|
|
}
|
|
|
|
public DeezerFake(IReadOnlyList<Song> fuzzyResults, IReadOnlyList<Song> titleOnlyResults, string? titleOnlySentinel = null)
|
|
{
|
|
_fuzzyResults = fuzzyResults;
|
|
_titleOnlyResults = titleOnlyResults;
|
|
_isrcResult = null;
|
|
_titleOnlySentinel = titleOnlySentinel;
|
|
}
|
|
|
|
public override Task<Song?> FindSongByIsrcAsync(string isrc, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(_isrcResult);
|
|
|
|
protected override IReadOnlyList<Song> SelectResults(string query) =>
|
|
_titleOnlySentinel != null && !query.Contains(_titleOnlySentinel)
|
|
? _titleOnlyResults
|
|
: _fuzzyResults;
|
|
}
|
|
|
|
private sealed class QobuzFake : SearchFakeBase
|
|
{
|
|
public override string ProviderId => "qobuz";
|
|
|
|
private readonly IReadOnlyList<Song> _fuzzyResults;
|
|
private readonly Song? _isrcResult;
|
|
|
|
public QobuzFake(IReadOnlyList<Song> fuzzyResults, Song? isrcResult = null)
|
|
{
|
|
_fuzzyResults = fuzzyResults;
|
|
_isrcResult = isrcResult;
|
|
}
|
|
|
|
public override Task<Song?> FindSongByIsrcAsync(string isrc, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(_isrcResult);
|
|
|
|
protected override IReadOnlyList<Song> SelectResults(string query) => _fuzzyResults;
|
|
}
|
|
|
|
private sealed class AmazonFake : SearchFakeBase
|
|
{
|
|
public override string ProviderId => "amazon";
|
|
|
|
protected override IReadOnlyList<Song> SelectResults(string query) =>
|
|
throw new InvalidOperationException("Provider unavailable");
|
|
}
|
|
}
|