Files
allstarr/allstarr.Tests/Api/IntelligenceControllerTests.cs
T

1236 lines
57 KiB
C#

using System.Data.Common;
using System.Diagnostics;
using System.Text.Json;
using allstarr.Controllers;
using allstarr.Core.Capabilities;
using allstarr.Core.Identity;
using allstarr.Core.Intelligence;
using allstarr.Core.Jobs;
using allstarr.Core.Playback;
using allstarr.Core.Storage;
using allstarr.Services.Admin;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace allstarr.Tests;
public sealed class IntelligenceControllerTests : IAsyncLifetime
{
private readonly Guid _tenant = Guid.CreateVersion7(); private readonly Guid _user = Guid.CreateVersion7();
private PostgresTestDatabase _database = null!;
private readonly CommandCounter _commands = new();
private Factory _factory = null!; private FakePolicy _policy = null!; private FakeRuns _runs = null!; private FakeSmart _smart = null!; private FakeReadiness _readiness = null!;
public async Task InitializeAsync()
{
_database = await PostgresTestDatabase.CreateAsync();
_factory = new(new DbContextOptionsBuilder<AllstarrDbContext>(_database.Options)
.AddInterceptors(_commands).Options);
await using var db = await _factory.CreateDbContextAsync();
db.Tenants.Add(new() { Id = _tenant, Slug = "intelligence", Name = "Intelligence", CreatedAt = DateTimeOffset.UtcNow });
db.Users.Add(new()
{
Id = _user,
TenantId = _tenant,
DisplayName = "Owner",
Status = PlatformUserStatus.Active,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
db.BackendIdentities.Add(new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
UserId = _user,
BackendType = "jellyfin",
BackendInstanceId = "main",
PrincipalId = "principal",
CreatedAt = DateTimeOffset.UtcNow,
LastSeenAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(); _policy = new(); _runs = new(); _smart = new(); _readiness = new();
}
[Fact]
public async Task Get_DerivesExactCallerScopeAndReturnsTruthfulEmptyOrUnauthorizedState()
{
var controller = Controller();
var empty = Assert.IsType<OkObjectResult>(await controller.Get(Scope(), default));
Assert.Contains("empty", JsonSerializer.Serialize(empty.Value), StringComparison.OrdinalIgnoreCase);
Assert.Equal(_tenant, _policy.LastScope!.TenantId); Assert.Equal(_user, _policy.LastScope.OwnerUserId);
var unauthorized = Assert.IsType<OkObjectResult>(await controller.Get(new()
{
Protocol = "jellyfin",
BackendInstanceId = "other",
LibraryScopeId = "music"
}, default));
Assert.Contains("unauthorized", JsonSerializer.Serialize(unauthorized.Value), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task AdministratorSessionPreservesSelectedOwnerScope()
{
var controller = Controller(administrator: true);
Assert.IsType<OkObjectResult>(await controller.Get(Scope(), default));
Assert.Equal(_tenant, _policy.LastScope!.TenantId);
Assert.Equal(_user, _policy.LastScope.OwnerUserId);
}
[Fact]
public async Task AdministratorMediaTargetsPreserveOwnerAndBackendCredentialScope()
{
var otherUser = Guid.CreateVersion7();
var selectedIdentity = Guid.CreateVersion7();
var otherIdentity = Guid.CreateVersion7();
var selectedCredential = Guid.CreateVersion7();
var otherCredential = Guid.CreateVersion7();
var now = DateTimeOffset.UtcNow;
await using (var db = await _factory.CreateDbContextAsync())
{
db.Users.Add(new()
{
Id = otherUser,
TenantId = _tenant,
DisplayName = "Other",
Status = PlatformUserStatus.Active,
CreatedAt = now,
UpdatedAt = now
});
db.BackendIdentities.AddRange(
new()
{
Id = selectedIdentity,
TenantId = _tenant,
UserId = _user,
BackendType = "subsonic",
BackendInstanceId = "selected",
PrincipalId = "selected",
CreatedAt = now,
LastSeenAt = now
},
new()
{
Id = otherIdentity,
TenantId = _tenant,
UserId = otherUser,
BackendType = "subsonic",
BackendInstanceId = "other",
PrincipalId = "other",
CreatedAt = now,
LastSeenAt = now
});
db.SecretReferences.AddRange(
new()
{
Id = selectedCredential,
TenantId = _tenant,
BackendIdentityId = selectedIdentity,
Purpose = BackendCredentialScope.SubsonicPurpose,
CreatedAt = now,
UpdatedAt = now
},
new()
{
Id = otherCredential,
TenantId = _tenant,
BackendIdentityId = otherIdentity,
Purpose = BackendCredentialScope.SubsonicPurpose,
CreatedAt = now,
UpdatedAt = now
});
await db.SaveChangesAsync();
}
var controller = new PlaylistLinksController(_factory, null!, null!, null!, null!, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
controller.ControllerContext = new() { HttpContext = new DefaultHttpContext() };
controller.HttpContext.Items[AdminAuthSessionService.HttpContextSessionItemKey] = Session(administrator: true);
var result = Assert.IsType<OkObjectResult>(await controller.ListMediaTargets(default));
var json = JsonSerializer.Serialize(result.Value);
Assert.Contains(selectedIdentity.ToString(), json, StringComparison.OrdinalIgnoreCase);
Assert.Contains(selectedCredential.ToString(), json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(otherIdentity.ToString(), json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(otherCredential.ToString(), json, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Get_IgnoresMalformedScheduleRowsInsteadOfBreakingTheIntelligenceScreen()
{
var policy = Policy();
_policy.Record = policy;
await using (var db = await _factory.CreateDbContextAsync())
{
db.IntelligencePolicies.Add(policy);
db.JobSchedules.Add(new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
OwnerUserId = _user,
LibraryScopeId = "music",
JobType = DurableScheduleEngine.RecommendationJobType,
CronExpression = "0 8 * * *",
TimeZoneId = "UTC",
RetryPolicyJson = "{}",
PayloadTemplateJson = "{broken",
Enabled = false,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
var result = Assert.IsType<OkObjectResult>(await Controller().Get(Scope(), default));
var json = JsonSerializer.Serialize(result.Value);
Assert.Contains("configured", json, StringComparison.OrdinalIgnoreCase);
Assert.Contains("\"schedules\":[]", json, StringComparison.Ordinal);
}
[Fact]
public async Task GetReportsExactListeningServiceAndSongDetailStatus()
{
_policy.Record = Policy();
var delivered = HistoryEvent("Delivered", DateTimeOffset.UtcNow.AddMinutes(-2));
delivered.MusicBrainzEnrichmentState = MusicBrainzEnrichmentState.Resolved;
var pending = HistoryEvent("Pending", DateTimeOffset.UtcNow.AddMinutes(-1));
pending.MusicBrainzEnrichmentState = MusicBrainzEnrichmentState.Pending;
var other = HistoryEvent("Other", DateTimeOffset.UtcNow, "other");
other.MusicBrainzEnrichmentState = MusicBrainzEnrichmentState.Failed;
await using (var db = await _factory.CreateDbContextAsync())
{
db.ListeningEvents.AddRange(delivered, pending, other);
db.PlaybackDeliveryCheckpoints.AddRange(
DeliveryCheckpoint(delivered.OccurrenceKey, "lastfm", ScopedPlaybackScrobbleOutcome.Delivered),
DeliveryCheckpoint(other.OccurrenceKey, "listenbrainz", ScopedPlaybackScrobbleOutcome.PermanentFailure));
await db.SaveChangesAsync();
}
var result = Assert.IsType<OkObjectResult>(await Controller(scrobbleTargets:
[new FakeScrobbleTarget("lastfm", true), new FakeScrobbleTarget("listenbrainz", false)])
.Get(Scope(), default));
var json = JsonSerializer.Serialize(result.Value);
Assert.Contains("Last.fm", json, StringComparison.Ordinal);
Assert.Contains("\"latestState\":\"delivered\"", json, StringComparison.Ordinal);
Assert.Contains("ListenBrainz", json, StringComparison.Ordinal);
Assert.Contains("AudioMuse-AI", json, StringComparison.Ordinal);
Assert.DoesNotContain("permanentfailure", json, StringComparison.Ordinal);
Assert.Contains("\"pending\":1", json, StringComparison.Ordinal);
Assert.Contains("\"resolved\":1", json, StringComparison.Ordinal);
Assert.Contains("\"failed\":0", json, StringComparison.Ordinal);
}
[Fact]
public async Task AudioMuseSimilarUsesTheExactSessionScopeAndReturnsPlainSongFacts()
{
var audioMuse = new FakeAudioMuse();
var result = Assert.IsType<OkObjectResult>(await Controller(audioMuse).GetAudioMuseSimilar(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
SeedTrackIds = ["seed-1"],
Limit = 10
}, default));
var json = JsonSerializer.Serialize(result.Value);
Assert.Equal(_tenant, audioMuse.Scope!.TenantId);
Assert.Equal(_user, audioMuse.Scope.OwnerUserId);
Assert.Contains("A song", json, StringComparison.Ordinal);
Assert.Contains("A clear reason", json, StringComparison.Ordinal);
}
[Fact]
public async Task AudioMuseFingerprintSendsOnlyBoundedExactScopeCompletedHistory()
{
var now = DateTimeOffset.UtcNow;
await using (var db = await _factory.CreateDbContextAsync())
{
db.ListeningEvents.AddRange(
HistoryEvent("recent", now.AddDays(-1)),
HistoryEvent("frequent", now.AddDays(-10)),
HistoryEvent("frequent", now.AddDays(-9)),
HistoryEvent("too-old", now.AddDays(-45)),
HistoryEvent("other-library", now.AddDays(-1), "other"));
await db.SaveChangesAsync();
}
var audioMuse = new FakeAudioMuse();
var result = Assert.IsType<OkObjectResult>(await Controller(audioMuse).GetAudioMuseFingerprint(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
PeriodDays = 30,
Limit = 10
}, default));
var json = JsonSerializer.Serialize(result.Value);
Assert.Equal(["frequent", "recent"], audioMuse.Seeds);
Assert.Contains("\"completedListens\":3", json, StringComparison.Ordinal);
Assert.DoesNotContain("too-old", json, StringComparison.Ordinal);
Assert.DoesNotContain("other-library", json, StringComparison.Ordinal);
}
[Fact]
public async Task AudioMuseClustersAreReturnedAsBoundedCursorPages()
{
var audioMuse = new FakeAudioMuse
{
Clusters =
[
new("cluster-1", "First group", []),
new("cluster-2", "Second group", []),
new("cluster-3", "Third group", [])
]
};
var first = Assert.IsType<OkObjectResult>(await Controller(audioMuse).GetAudioMuseClusters(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Limit = 1
}, default));
var firstJson = JsonSerializer.Serialize(first.Value);
Assert.Contains("First group", firstJson, StringComparison.Ordinal);
Assert.Contains("\"nextCursor\":\"1\"", firstJson, StringComparison.Ordinal);
Assert.DoesNotContain("Second group", firstJson, StringComparison.Ordinal);
Assert.Equal(2, audioMuse.ClusterLimit);
var second = Assert.IsType<OkObjectResult>(await Controller(audioMuse).GetAudioMuseClusters(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Limit = 1,
Cursor = "1"
}, default));
var secondJson = JsonSerializer.Serialize(second.Value);
Assert.Contains("Second group", secondJson, StringComparison.Ordinal);
Assert.Contains("\"nextCursor\":\"2\"", secondJson, StringComparison.Ordinal);
Assert.Equal(3, audioMuse.ClusterLimit);
}
[Fact]
public async Task AudioMusePreviewCreatesOnlyAnExactScopeGeneratedPlaylist()
{
_policy.Record = Policy();
_policy.Record.EnabledProvidersJson = "[\"audiomuse-ai\"]";
await using (var db = await _factory.CreateDbContextAsync())
{
var backendIdentityId = await db.BackendIdentities.Select(item => item.Id).SingleAsync();
db.LibraryTracks.AddRange(
LocalTrack(backendIdentityId, "song-2", "Second"),
LocalTrack(backendIdentityId, "song-1", "First"),
LocalTrack(backendIdentityId, "other-song", "Other", "other"));
await db.SaveChangesAsync();
}
var result = Assert.IsType<AcceptedResult>(await Controller().CreateAudioMuseGeneratedSet(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Name = "Evening sounds",
TrackIds = ["song-1", "song-2"],
IdempotencyKey = "sound-preview-1"
}, default));
Assert.Contains("creating", JsonSerializer.Serialize(result.Value), StringComparison.Ordinal);
Assert.Equal("Evening sounds", _smart.PreviewName);
Assert.Equal("sound-preview-1", _smart.IdempotencyKey);
var candidates = Assert.IsAssignableFrom<IReadOnlyList<RecommendationCandidate>>(_smart.Candidates);
Assert.Equal(["song-1", "song-2"], candidates.Select(item => item.TrackKey));
Assert.All(candidates, item => Assert.Equal("audiomuse-ai", item.Source));
var crossed = await Controller().CreateAudioMuseGeneratedSet(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Name = "Wrong library",
TrackIds = ["other-song"],
IdempotencyKey = "sound-preview-2"
}, default);
Assert.IsType<ConflictObjectResult>(crossed);
}
[Fact]
public async Task HistoryAggregatesRespectBoundariesTimezoneUnknownDurationTiesFiltersAndMutations()
{
var from = DateTimeOffset.Parse("2026-03-08T04:00:00Z");
var to = DateTimeOffset.Parse("2026-03-10T04:00:00Z");
var firstA = HistoryEvent("Track A", from);
var secondA = HistoryEvent("Track A", DateTimeOffset.Parse("2026-03-08T05:30:00Z"));
var trackB = HistoryEvent("Track B", DateTimeOffset.Parse("2026-03-09T04:30:00Z"));
var trackC = HistoryEvent("Track C", trackB.ListenedAt!.Value);
foreach (var item in new[] { firstA, secondA })
{
item.Artist = "Artist A";
item.Album = "Album A";
item.SourceKind = "import";
item.ClientClass = "Koito";
item.ProviderId = "spotify";
item.ProviderTrackReference = "spotify:track:track-a";
}
firstA.DurationMilliseconds = 1_000;
secondA.DurationMilliseconds = null;
trackB.Artist = "Artist B";
trackB.Album = "Album B";
trackB.ClientClass = "Feishin";
trackB.ProviderId = "deezer";
trackB.DurationMilliseconds = 2_000;
trackC.Artist = "Artist C";
trackC.Album = "Album C";
trackC.ClientClass = "Finer";
trackC.ProviderId = "deezer";
trackC.DurationMilliseconds = 3_000;
var before = HistoryEvent("Before", from.AddMilliseconds(-1));
var upperBound = HistoryEvent("Upper bound", to);
var otherLibrary = HistoryEvent("Other library", from.AddHours(2), "other");
var skipped = HistoryEvent("Skipped", from.AddHours(2));
skipped.State = ListeningEventState.Skipped;
skipped.ListenedAt = null;
skipped.StartedAt = from.AddHours(2);
await using (var db = await _factory.CreateDbContextAsync())
{
db.ListeningEvents.AddRange(
firstA, secondA, trackB, trackC, before, upperBound, otherLibrary, skipped);
await db.SaveChangesAsync();
}
_commands.Reset();
var elapsed = Stopwatch.StartNew();
var overviewResult = Assert.IsType<OkObjectResult>(await Controller().GetHistoryOverview(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
From = from,
To = to,
TimeZoneId = "America/New_York"
}, default));
elapsed.Stop();
Assert.InRange(_commands.Count, 7, 9);
Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(2),
$"History overview took {elapsed.Elapsed.TotalMilliseconds:F0} ms in {_commands.Count} queries.");
var overview = JsonSerializer.SerializeToElement(overviewResult.Value);
var allTime = overview.GetProperty("allTime");
var selected = overview.GetProperty("selected");
Assert.Equal(6, allTime.GetProperty("CompletedListens").GetInt32());
Assert.Equal(4, selected.GetProperty("CompletedListens").GetInt32());
Assert.Equal(3, selected.GetProperty("DistinctTracks").GetInt32());
Assert.Equal(3, selected.GetProperty("DistinctArtists").GetInt32());
Assert.Equal(6_000, selected.GetProperty("ListeningTimeMilliseconds").GetInt64());
Assert.Equal(from, selected.GetProperty("FirstListen").GetDateTimeOffset());
Assert.True(overview.GetProperty("currentStreakDays").GetInt32() == 3, overview.GetRawText());
Assert.True(overview.GetProperty("longestStreakDays").GetInt32() == 3, overview.GetRawText());
var breakdowns = overview.GetProperty("breakdowns");
Assert.Equal(["import", "protocol"], breakdowns.GetProperty("Sources").EnumerateArray()
.Select(item => item.GetProperty("Value").GetString()));
Assert.Equal(["deezer", "spotify"], breakdowns.GetProperty("Providers").EnumerateArray()
.Select(item => item.GetProperty("Value").GetString()));
Assert.Equal(["Koito", "Feishin", "Finer"], breakdowns.GetProperty("Clients").EnumerateArray()
.Select(item => item.GetProperty("Value").GetString()));
var activityResult = Assert.IsType<OkObjectResult>(await Controller().GetHistoryActivity(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
From = from,
To = to,
TimeZoneId = "America/New_York"
}, default));
var activity = JsonSerializer.SerializeToElement(activityResult.Value);
var buckets = activity.GetProperty("buckets").EnumerateArray().ToArray();
Assert.Equal(["2026-03-07", "2026-03-08", "2026-03-09"],
buckets.Select(item => item.GetProperty("Date").GetString()));
Assert.Equal([1, 1, 2], buckets.Select(item => item.GetProperty("Count").GetInt32()));
Assert.Equal([1_000L, 0L, 5_000L],
buckets.Select(item => item.GetProperty("DurationMilliseconds").GetInt64()));
Assert.Equal([1, 1, 0],
buckets.Select(item => item.GetProperty("ImportedCount").GetInt32()));
Assert.Equal([0, 0, 2],
buckets.Select(item => item.GetProperty("PlaybackCount").GetInt32()));
var topResult = Assert.IsType<OkObjectResult>(await Controller().GetHistoryTopItems("track", new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
From = from,
To = to
}, default));
var top = JsonSerializer.SerializeToElement(topResult.Value).GetProperty("items").EnumerateArray().ToArray();
Assert.Equal(["Track A", "Track B", "Track C"],
top.Select(item => item.GetProperty("Title").GetString()));
Assert.Equal([2, 1, 1], top.Select(item => item.GetProperty("ListenCount").GetInt32()));
var filteredResult = Assert.IsType<OkObjectResult>(await Controller().GetHistory(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
From = from,
To = to,
Source = "import",
Client = "Koito",
Artist = "Artist A",
Album = "Album A",
Track = "Track A",
Search = "Track A"
}, default));
var filtered = JsonSerializer.SerializeToElement(filteredResult.Value).GetProperty("items")
.EnumerateArray().ToArray();
Assert.Equal(2, filtered.Length);
Assert.All(filtered, item => Assert.Equal("Track A", item.GetProperty("Title").GetString()));
Assert.All(filtered, item => Assert.Equal(
"/api/admin/downloads/artwork/ext-spotify-song-track-a",
item.GetProperty("ArtworkUrl").GetString()));
Assert.IsType<OkObjectResult>(await Controller().CorrectHistory(trackC.Id, new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Title = "Track B",
Artist = "Artist B",
Album = "Album B",
ExpectedRevision = trackC.Revision
}, default));
Assert.IsType<NoContentResult>(await Controller().DeleteHistory(trackB.Id, new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
ExpectedRevision = trackB.Revision,
Confirmed = true
}, default));
var mutatedResult = Assert.IsType<OkObjectResult>(await Controller().GetHistoryOverview(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
From = from,
To = to,
TimeZoneId = "America/New_York"
}, default));
var mutated = JsonSerializer.SerializeToElement(mutatedResult.Value).GetProperty("selected");
Assert.Equal(3, mutated.GetProperty("CompletedListens").GetInt32());
Assert.Equal(2, mutated.GetProperty("DistinctTracks").GetInt32());
Assert.Equal(2, mutated.GetProperty("DistinctArtists").GetInt32());
Assert.Equal(4_000, mutated.GetProperty("ListeningTimeMilliseconds").GetInt64());
}
[Fact]
public async Task HistoryRoutesPageCorrectAndDeleteOnlyTheExactScope()
{
var now = DateTimeOffset.UtcNow;
var first = HistoryEvent("First", now.AddMinutes(-1));
var second = HistoryEvent("Second", now.AddMinutes(-2));
var third = HistoryEvent("Third", now.AddMinutes(-3));
var otherLibrary = HistoryEvent("Other library", now.AddMinutes(-4), "other");
await using (var db = await _factory.CreateDbContextAsync())
{
db.ListeningEvents.AddRange(first, second, third, otherLibrary);
db.Set<allstarr.Core.Playback.PlaybackDeliveryCheckpointEntity>().Add(new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
OwnerUserId = _user,
OccurrenceKey = first.OccurrenceKey,
SignalKey = new string('a', 64),
TargetId = "lastfm",
Kind = allstarr.Core.Playback.PlaybackScrobbleDeliveryKind.Completed,
State = allstarr.Core.Playback.ScopedPlaybackScrobbleOutcome.Delivered,
UpdatedAt = now
});
await db.SaveChangesAsync();
}
var page = Assert.IsType<OkObjectResult>(await Controller().GetHistory(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
From = now.AddDays(-1),
To = now.AddDays(1),
Limit = 2
}, default));
var pageJson = JsonSerializer.Serialize(page.Value);
Assert.Contains("First", pageJson, StringComparison.Ordinal);
Assert.Contains("Second", pageJson, StringComparison.Ordinal);
Assert.DoesNotContain("Third", pageJson, StringComparison.Ordinal);
Assert.DoesNotContain("Other library", pageJson, StringComparison.Ordinal);
Assert.Contains("lastfm", pageJson, StringComparison.Ordinal);
using var pageDocument = JsonDocument.Parse(pageJson);
var cursor = pageDocument.RootElement.GetProperty("nextCursor").GetString();
var nextPage = Assert.IsType<OkObjectResult>(await Controller().GetHistory(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
From = now.AddDays(-1),
To = now.AddDays(1),
Limit = 2,
Cursor = cursor
}, default));
var nextPageJson = JsonSerializer.Serialize(nextPage.Value);
Assert.Contains("Third", nextPageJson, StringComparison.Ordinal);
Assert.DoesNotContain("First", nextPageJson, StringComparison.Ordinal);
Assert.IsType<OkObjectResult>(await Controller().GetHistoryOverview(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
From = now.AddDays(-1),
To = now.AddDays(1),
TimeZoneId = "UTC"
}, default));
Assert.IsType<OkObjectResult>(await Controller().GetHistoryActivity(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
From = now.AddDays(-1),
To = now.AddDays(1),
TimeZoneId = "UTC"
}, default));
Assert.IsType<OkObjectResult>(await Controller().GetHistoryTopItems("track", new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
From = now.AddDays(-1),
To = now.AddDays(1)
}, default));
var exportController = Controller();
await using var exportBody = new MemoryStream();
exportController.HttpContext.Response.Body = exportBody;
var export = await exportController.ExportHistory(Scope(), default);
await export.ExecuteResultAsync(exportController.ControllerContext);
var exportJson = System.Text.Encoding.UTF8.GetString(exportBody.ToArray());
Assert.Contains("allstarr-listening-history", exportJson, StringComparison.Ordinal);
Assert.Contains("First", exportJson, StringComparison.Ordinal);
Assert.DoesNotContain("Other library", exportJson, StringComparison.Ordinal);
var corrected = Assert.IsType<OkObjectResult>(await Controller().CorrectHistory(first.Id, new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Title = "Corrected",
Artist = "Artist",
Album = "Album",
ExpectedRevision = first.Revision
}, default));
Assert.Contains("Revision", JsonSerializer.Serialize(corrected.Value), StringComparison.Ordinal);
var staleDelete = await Controller().DeleteHistory(first.Id, new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
ExpectedRevision = first.Revision,
Confirmed = true
}, default);
Assert.IsType<ConflictObjectResult>(staleDelete);
Assert.IsType<NoContentResult>(await Controller().DeleteHistory(first.Id, new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
ExpectedRevision = first.Revision + 1,
Confirmed = true
}, default));
await using var verify = await _factory.CreateDbContextAsync();
Assert.Equal(3, await verify.ListeningEvents.CountAsync());
Assert.Empty(await verify.Set<allstarr.Core.Playback.PlaybackDeliveryCheckpointEntity>().ToListAsync());
Assert.Contains(await verify.AuditEvents.ToListAsync(), item =>
item.Category == "listening-history" && item.Action == "corrected");
Assert.Contains(await verify.AuditEvents.ToListAsync(), item =>
item.Category == "listening-history" && item.Action == "deleted");
}
[Fact]
public async Task Policy_UsesCallerIdentityAndRejectsUnavailableStaticPromise()
{
var controller = Controller();
var unavailable = await controller.SetPolicy(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Enabled = true,
RetentionDays = 30,
EnabledProviders = ["not-registered"]
}, default);
Assert.IsType<BadRequestObjectResult>(unavailable);
Assert.IsType<OkObjectResult>(await controller.SetPolicy(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Enabled = true,
RetentionDays = 30,
AllowedSignalTypes = ["play", "favorite"],
EnabledProviders = ["lastfm"]
}, default));
Assert.Equal(_user, _policy.LastScope!.OwnerUserId); Assert.Equal(["lastfm"], _policy.LastInput!.EnabledProviders);
_readiness.LastFmState = RecommendationProviderReadinessState.Unauthorized;
var notReady = await controller.SetPolicy(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Enabled = true,
RetentionDays = 30,
EnabledProviders = ["lastfm"]
}, default);
Assert.IsType<ConflictObjectResult>(notReady);
}
[Fact]
public async Task Get_ShowsOnlyScopedCandidatesExplanationsProfilesAndGeneratedPreviews()
{
_policy.Record = Policy(); var run = Guid.CreateVersion7(); var job = Guid.CreateVersion7();
await using (var db = await _factory.CreateDbContextAsync())
{
db.Jobs.Add(new()
{
Id = job,
ScopeKey = $"{_tenant:N}:{_user:N}",
TenantId = _tenant,
OwnerUserId = _user,
LibraryScopeId = "music",
Type = "recommendation.generate",
PayloadJson = "{}",
PolicySnapshotJson = "{}",
RequestFingerprint = new string('a', 64),
IdempotencyKey = "run",
CorrelationId = "test",
State = DurableJobState.Succeeded,
MaxAttempts = 1,
AvailableAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
db.AuditEvents.Add(new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
ActorUserId = _user,
Category = "job-progress",
Action = "recommendation.rank",
Outcome = "running",
CorrelationId = "test",
DetailsJson = """{"stage":"recommendation.rank","message":"Ranking tracks.","completed":1,"total":2}""",
CreatedAt = DateTimeOffset.UtcNow
});
db.RecommendationRuns.Add(new()
{
Id = run,
TenantId = _tenant,
OwnerUserId = _user,
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
JobId = job,
IdempotencyKey = "run",
Limit = 10,
State = RecommendationRunState.Succeeded,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
var candidateId = Guid.Parse("33333333-3333-3333-3333-333333333333");
db.RecommendationCandidates.Add(new()
{
Id = candidateId,
RunId = run,
TenantId = _tenant,
OwnerUserId = _user,
Position = 0,
TrackKey = "local:42",
Score = .9,
Source = "lastfm",
SourceRevision = "lastfm:fixture",
SignalsJson = JsonSerializer.Serialize(new[] { new RecommendationSignal("similar", .9, "Shared listening context") }),
IdentityJson = JsonSerializer.Serialize(new RecommendationTrackIdentity(
MusicBrainzRecordingId: "11111111-1111-1111-1111-111111111111",
Title: "Recommended song",
Artist: "Recommended artist",
LibraryTrackId: Guid.Parse("22222222-2222-2222-2222-222222222222"),
BackendItemId: "backend-track-42")),
CreatedAt = DateTimeOffset.UtcNow
});
var set = Guid.CreateVersion7(); db.GeneratedSets.Add(new()
{
Id = set,
RunId = run,
TenantId = _tenant,
OwnerUserId = _user,
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Name = "Private preview",
CreatedAt = DateTimeOffset.UtcNow
});
db.GeneratedSetEntries.Add(new() { Id = Guid.CreateVersion7(), GeneratedSetId = set, TenantId = _tenant, OwnerUserId = _user, Position = 0, TrackKey = "local:42" });
db.ListeningProfiles.Add(new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
OwnerUserId = _user,
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
ProfileJson = JsonSerializer.Serialize(new ListeningProfile(_tenant, _user, "main", "music", 4, 1, 2,
new Dictionary<string, double>(), DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow)),
WindowStart = DateTimeOffset.UtcNow.AddDays(-1),
WindowEnd = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
_commands.Reset(); var elapsed = Stopwatch.StartNew();
var result = Assert.IsType<OkObjectResult>(await Controller().Get(Scope(), default));
elapsed.Stop(); var json = JsonSerializer.Serialize(result.Value);
Assert.InRange(_commands.Count, 1, 12);
Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(2),
$"Intelligence read took {elapsed.Elapsed.TotalMilliseconds:F0} ms.");
Assert.Contains("Shared listening context", json); Assert.Contains("Private preview", json); Assert.Contains("visualization", json);
Assert.Contains("lastfm:fixture", json, StringComparison.Ordinal);
Assert.Contains("Recommended song", json, StringComparison.Ordinal);
Assert.Contains("\"latestRunState\":\"succeeded\"", json, StringComparison.Ordinal);
Assert.Contains("Ranking tracks.", json, StringComparison.Ordinal);
Assert.DoesNotContain("TenantId", json, StringComparison.Ordinal);
var feedback = Assert.IsType<OkObjectResult>(await Controller().SetFeedback(
Guid.Parse("33333333-3333-3333-3333-333333333333"), new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Kind = "dislike",
ReasonCode = "not-my-style",
ExpectedRevision = 0
}, default));
Assert.Contains("dislike", JsonSerializer.Serialize(feedback.Value), StringComparison.Ordinal);
Assert.IsType<NotFoundResult>(await Controller().SetFeedback(
Guid.Parse("33333333-3333-3333-3333-333333333333"), new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "other",
Kind = "dismiss",
ExpectedRevision = 1
}, default));
Assert.IsType<OkObjectResult>(await Controller().GenerateSet(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
RunId = run,
Name = "Generated mix"
}, default));
var generatedCandidate = Assert.Single(_smart.Candidates!);
Assert.Equal(Guid.Parse("22222222-2222-2222-2222-222222222222"), generatedCandidate.Identity!.LibraryTrackId);
Assert.Equal("backend-track-42", generatedCandidate.Identity.BackendItemId);
await using (var db = await _factory.CreateDbContextAsync())
{
(await db.BackendIdentities.SingleAsync()).BackendInstanceId = "removed";
await db.SaveChangesAsync();
}
Assert.IsType<NotFoundResult>(await Controller().SetFeedback(
Guid.Parse("33333333-3333-3333-3333-333333333333"), new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Kind = "dismiss",
ExpectedRevision = 1
}, default));
}
[Fact]
public async Task RunAndGeneratedPreview_UseExactSessionScope()
{
_policy.Record = Policy(); var controller = Controller();
Assert.IsType<AcceptedResult>(await controller.Enqueue(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Limit = 20,
IdempotencyKey = "request-1"
}, default));
Assert.Equal(_user, _runs.Scope!.OwnerUserId);
}
[Fact]
public async Task ScheduleCrudIsExactScopeRevisionCheckedAndSoftDisables()
{
var policy = Policy(); _policy.Record = policy;
await using (var db = await _factory.CreateDbContextAsync())
{
db.IntelligencePolicies.Add(policy);
await db.SaveChangesAsync();
}
var controller = Controller();
var created = Assert.IsType<CreatedResult>(await controller.CreateSchedule(new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
CronExpression = "0 3 * * *",
TimeZoneId = "UTC",
OverlapPolicy = "skip",
MisfirePolicy = "runOnce",
Enabled = true,
Limit = 40,
Name = "Morning discovery"
}, default));
var createdJson = JsonSerializer.SerializeToElement(created.Value);
var scheduleId = createdJson.GetProperty("id").GetGuid();
Assert.Equal(0, createdJson.GetProperty("Revision").GetInt64());
var conflict = await controller.UpdateSchedule(scheduleId, new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
CronExpression = "0 4 * * *",
TimeZoneId = "UTC",
OverlapPolicy = "queue",
MisfirePolicy = "skip",
Enabled = true,
Limit = 25,
Name = "Updated discovery",
ExpectedRevision = 99
}, default);
Assert.IsType<ConflictObjectResult>(conflict);
var updated = Assert.IsType<OkObjectResult>(await controller.UpdateSchedule(scheduleId, new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
CronExpression = "0 4 * * *",
TimeZoneId = "UTC",
OverlapPolicy = "queue",
MisfirePolicy = "skip",
Enabled = true,
Limit = 25,
Name = "Updated discovery",
ExpectedRevision = 0
}, default));
Assert.Contains("Updated discovery", JsonSerializer.Serialize(updated.Value), StringComparison.Ordinal);
Assert.IsType<NoContentResult>(await controller.DeleteSchedule(scheduleId, new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
ExpectedRevision = 1
}, default));
await using var verify = await _factory.CreateDbContextAsync();
var stored = await verify.JobSchedules.SingleAsync();
Assert.False(stored.Enabled); Assert.Null(stored.NextRunAt); Assert.Equal(2, stored.Revision);
var template = JsonSerializer.Deserialize<RecommendationScheduleTemplate>(stored.PayloadTemplateJson)!;
Assert.Equal(policy.Id, template.IntelligencePolicyId);
}
[Fact]
public async Task DeleteScheduleRejectsUnownedBackendBeforeReadingSchedule()
{
var policy = Policy();
await using (var db = await _factory.CreateDbContextAsync())
{
db.IntelligencePolicies.Add(policy);
db.JobSchedules.Add(new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
OwnerUserId = _user,
LibraryScopeId = "music",
JobType = DurableScheduleEngine.RecommendationJobType,
CronExpression = "0 3 * * *",
TimeZoneId = "UTC",
RetryPolicyJson = "{}",
PayloadTemplateJson = JsonSerializer.Serialize(new RecommendationScheduleTemplate(
1, policy.Id, 25, "Discovery")),
Enabled = true,
NextRunAt = DateTimeOffset.UtcNow.AddDays(1),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
await using var read = await _factory.CreateDbContextAsync();
var id = await read.JobSchedules.Select(item => item.Id).SingleAsync();
var result = await Controller().DeleteSchedule(id, new()
{
Protocol = "jellyfin",
BackendInstanceId = "not-owned",
LibraryScopeId = "music",
ExpectedRevision = 0
}, default);
Assert.IsType<NotFoundResult>(result);
}
[Fact]
public async Task UpdateScheduleCannotTakeOverAnotherPolicySchedule()
{
var policy = Policy();
var scheduleId = Guid.CreateVersion7();
await using (var db = await _factory.CreateDbContextAsync())
{
db.IntelligencePolicies.Add(policy);
db.JobSchedules.Add(new()
{
Id = scheduleId,
TenantId = _tenant,
OwnerUserId = _user,
LibraryScopeId = "music",
JobType = DurableScheduleEngine.RecommendationJobType,
CronExpression = "0 3 * * *",
TimeZoneId = "UTC",
RetryPolicyJson = "{}",
PayloadTemplateJson = JsonSerializer.Serialize(new RecommendationScheduleTemplate(
1, Guid.CreateVersion7(), 25, "Another backend's discovery")),
Enabled = true,
NextRunAt = DateTimeOffset.UtcNow.AddDays(1),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
var result = await Controller().UpdateSchedule(scheduleId, new()
{
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
CronExpression = "0 4 * * *",
TimeZoneId = "UTC",
OverlapPolicy = "skip",
MisfirePolicy = "runOnce",
Enabled = true,
Limit = 25,
Name = "Taken over",
ExpectedRevision = 0
}, default);
Assert.IsType<NotFoundResult>(result);
await using var verify = await _factory.CreateDbContextAsync();
Assert.DoesNotContain("Taken over", (await verify.JobSchedules.SingleAsync()).PayloadTemplateJson,
StringComparison.Ordinal);
}
private IntelligenceController Controller(FakeAudioMuse? audioMuse = null,
IReadOnlyList<IExactScopePlaybackScrobbleTarget>? scrobbleTargets = null,
bool administrator = false)
{
var value = new IntelligenceController(_factory, _policy, _runs, _smart, _readiness,
[new FakeProvider("lastfm"), new FakeProvider("musicbrainz-local"), new FakeProvider("audiomuse-ai")],
audioMuse ?? new FakeAudioMuse(), scrobbleTargets: scrobbleTargets);
value.ControllerContext = new() { HttpContext = new DefaultHttpContext() };
value.HttpContext.Items[AdminAuthSessionService.HttpContextSessionItemKey] = Session(administrator);
return value;
}
private AdminAuthSession Session(bool administrator) => new()
{
SessionId = "session",
UserId = "backend",
UserName = "Owner",
IsAdministrator = administrator,
JellyfinAccessToken = "token",
TenantId = _tenant,
AllstarrUserId = _user,
ExpiresAtUtc = DateTime.UtcNow.AddHours(1),
LastSeenUtc = DateTime.UtcNow
};
private IntelligenceScopeRequest Scope() => new() { Protocol = "jellyfin", BackendInstanceId = "main", LibraryScopeId = "music" };
private IntelligencePolicyRecord Policy() => new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
OwnerUserId = _user,
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = "music",
Enabled = true,
RetentionDays = 30,
AllowedSignalTypesJson = "[\"play\"]",
EnabledProvidersJson = "[\"lastfm\"]",
Revision = 1
};
private ListeningEventRecord HistoryEvent(string title, DateTimeOffset listenedAt, string library = "music") => new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
OwnerUserId = _user,
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = library,
OccurrenceKey = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Guid.NewGuid().ToByteArray())).ToLowerInvariant(),
State = ListeningEventState.Completed,
ListenedAt = listenedAt,
UpdatedAt = listenedAt,
SourceKind = "protocol",
TrackReference = title,
Title = title,
Artist = "Artist",
Revision = 1
};
private PlaybackDeliveryCheckpointEntity DeliveryCheckpoint(string occurrenceKey, string target,
ScopedPlaybackScrobbleOutcome state) => new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
OwnerUserId = _user,
OccurrenceKey = occurrenceKey,
SignalKey = Convert.ToHexStringLower(
System.Security.Cryptography.SHA256.HashData(Guid.NewGuid().ToByteArray())),
TargetId = target,
Kind = PlaybackScrobbleDeliveryKind.Completed,
State = state,
UpdatedAt = DateTimeOffset.UtcNow
};
private LibraryTrackRecord LocalTrack(Guid backendIdentityId, string id, string title,
string library = "music") => new()
{
Id = Guid.CreateVersion7(),
TenantId = _tenant,
OwnerUserId = _user,
BackendIdentityId = backendIdentityId,
Protocol = "jellyfin",
BackendInstanceId = "main",
LibraryScopeId = library,
BackendItemId = id,
FilePath = $"/music/{id}.flac",
Title = title,
Artist = "Artist",
ProviderIdsJson = "{}",
IndexedAt = DateTimeOffset.UtcNow,
SourceModifiedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
Revision = 1
};
public async Task DisposeAsync() => await _database.DisposeAsync();
private sealed class Factory(DbContextOptions<AllstarrDbContext> options) : IDbContextFactory<AllstarrDbContext>
{ public AllstarrDbContext CreateDbContext() => new(options); public Task<AllstarrDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(CreateDbContext()); }
private sealed class CommandCounter : DbCommandInterceptor
{
private int _count;
public int Count => Volatile.Read(ref _count);
public void Reset() => Interlocked.Exchange(ref _count, 0);
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _count);
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
private sealed class FakePolicy : IIntelligencePolicyService
{
public IntelligencePolicyRecord? Record { get; set; }
public IntelligenceScope? LastScope { get; private set; }
public IntelligencePolicyInput? LastInput { get; private set; }
public Task<IntelligencePolicyRecord?> GetAsync(IntelligenceScope scope, CancellationToken cancellationToken = default) { LastScope = scope; return Task.FromResult(Record); }
public Task<IntelligencePolicyRecord> SetAsync(IntelligenceScope scope, IntelligencePolicyInput input, CancellationToken cancellationToken = default) { LastScope = scope; LastInput = input; Record ??= new() { Id = Guid.CreateVersion7(), TenantId = scope.TenantId, OwnerUserId = scope.OwnerUserId, Protocol = scope.Protocol, BackendInstanceId = scope.BackendInstanceId, LibraryScopeId = scope.LibraryScopeId }; Record.Enabled = input.Enabled; Record.RetentionDays = input.RetentionDays; Record.Revision++; return Task.FromResult(Record); }
public Task DisableAndPurgeAsync(IntelligenceScope scope, CancellationToken cancellationToken = default) { LastScope = scope; return Task.CompletedTask; }
}
private sealed class FakeRuns : IRecommendationRunService { public IntelligenceScope? Scope { get; private set; } public Task<RecommendationRunReceipt> EnqueueAsync(IntelligenceScope scope, IReadOnlyList<string> seeds, int limit, string idempotencyKey, CancellationToken cancellationToken = default) { Scope = scope; return Task.FromResult(new RecommendationRunReceipt(Guid.CreateVersion7(), Guid.CreateVersion7(), true, RecommendationRunState.Pending)); } }
private sealed class FakeSmart : ISmartPlaylistService
{
public IReadOnlyList<RecommendationCandidate>? Candidates { get; private set; }
public string? PreviewName { get; private set; }
public string? IdempotencyKey { get; private set; }
public Task<Guid> CreateGeneratedSetAsync(IntelligenceScope scope, Guid runId, string name,
IReadOnlyList<RecommendationCandidate> candidates, CancellationToken cancellationToken = default)
{
Candidates = candidates;
return Task.FromResult(Guid.CreateVersion7());
}
public Task<Guid> CreateGeneratedSetAsync(IntelligenceScope scope, string name,
IReadOnlyList<RecommendationCandidate> candidates, string idempotencyKey,
CancellationToken cancellationToken = default)
{
PreviewName = name; Candidates = candidates; IdempotencyKey = idempotencyKey;
return Task.FromResult(Guid.CreateVersion7());
}
}
private sealed class FakeProvider(string id) : IRecommendationProvider { public string Id => id; public Task<RecommendationProviderResult> RecommendAsync(RecommendationRequest request) => Task.FromResult(new RecommendationProviderResult(RecommendationProviderState.Succeeded, [])); }
private sealed class FakeScrobbleTarget(string id, bool configured) : IExactScopePlaybackScrobbleTarget
{
public string ProviderId => id;
public Task<bool> IsConfiguredAsync(IntelligenceScope scope, CancellationToken cancellationToken) =>
Task.FromResult(configured);
public Task<ScopedPlaybackScrobbleResult> DeliverAsync(IntelligenceScope scope,
PlaybackTransition transition, ScopedPlaybackTrack track, long? positionTicks,
DateTimeOffset observedAt, string signalKey, CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
private sealed class FakeAudioMuse : IAudioMuseRecommendationClient
{
public bool IsAvailable => true;
public IntelligenceScope? Scope { get; private set; }
public IReadOnlyList<string>? Seeds { get; private set; }
public IReadOnlyList<AudioMuseCluster> Clusters { get; init; } = [];
public int ClusterLimit { get; private set; }
public Task<bool> CheckHealthAsync(IntelligenceScope scope, CancellationToken cancellationToken) => Task.FromResult(true);
public Task<IReadOnlyList<RecommendationSourceItem>> RecommendAsync(ScopedRecommendationQuery query, CancellationToken cancellationToken)
{
Seeds = query.SeedTrackKeys;
return FindSimilarAsync(query.Scope, query.SeedTrackKeys, query.Limit, cancellationToken);
}
public Task<IReadOnlyList<RecommendationSourceItem>> FindSimilarAsync(IntelligenceScope scope,
IReadOnlyList<string> seedTrackIds, int limit, CancellationToken cancellationToken)
{
Scope = scope;
return Task.FromResult<IReadOnlyList<RecommendationSourceItem>>([
new("song-1", .8, [new("similar", .8, "A clear reason")],
new("audiomuse-ai", Title: "A song", Artist: "An artist", BackendItemId: "song-1"))]);
}
public Task<ProviderAnalysisProgress> StartAnalysisAsync(IntelligenceScope scope, bool rebuild,
string idempotencyKey, CancellationToken cancellationToken) =>
Task.FromResult(new ProviderAnalysisProgress("job-1", ProviderAnalysisState.Queued, 0, 1));
public Task<ProviderAnalysisProgress> GetAnalysisProgressAsync(IntelligenceScope scope, string jobId,
CancellationToken cancellationToken) =>
Task.FromResult(new ProviderAnalysisProgress(jobId, ProviderAnalysisState.Completed, 1, 1));
public Task<IReadOnlyList<AudioMuseCluster>> GetClustersAsync(IntelligenceScope scope, int limit,
CancellationToken cancellationToken)
{
ClusterLimit = limit;
return Task.FromResult<IReadOnlyList<AudioMuseCluster>>(Clusters.Take(limit).ToArray());
}
public Task<IReadOnlyList<RecommendationSourceItem>> SearchAsync(IntelligenceScope scope, string query,
bool includeLyrics, int limit, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<RecommendationSourceItem>>([]);
public Task<AudioMusePathResult> FindPathAsync(IntelligenceScope scope, string startTrackId,
string endTrackId, int limit, CancellationToken cancellationToken) =>
Task.FromResult(new AudioMusePathResult([], 0));
public Task<IReadOnlyList<RecommendationSourceItem>> BlendAsync(IntelligenceScope scope,
IReadOnlyList<string> positiveSeedTrackIds, IReadOnlyList<string> negativeSeedTrackIds,
int limit, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<RecommendationSourceItem>>([]);
public Task<AudioMuseMapPage> GetMapAsync(IntelligenceScope scope, ProviderPageRequest page,
CancellationToken cancellationToken) => Task.FromResult(new AudioMuseMapPage([], "none"));
}
private sealed class FakeReadiness : IRecommendationProviderStatusService { public RecommendationProviderReadinessState LastFmState { get; set; } = RecommendationProviderReadinessState.Ready; public Task<IReadOnlyList<RecommendationProviderReadiness>> ListAsync(IntelligenceScope scope, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<RecommendationProviderReadiness>>([new("lastfm", LastFmState, LastFmState == RecommendationProviderReadinessState.Ready ? "fixture_ready" : "account_unauthorized"), new("musicbrainz-local", RecommendationProviderReadinessState.Ready, "fixture_ready"), new("audiomuse-ai", RecommendationProviderReadinessState.Ready, "fixture_ready")]); }
}