fix(search): collapse verified provider results safely
CI / build-and-test (push) Canceled after 0s
CI / release-critical-tests (push) Canceled after 0s
CI / csharp-format (push) Canceled after 0s
CI / webui (push) Canceled after 0s
CI / apple-contracts (push) Canceled after 0s
CI / compose-contracts (push) Canceled after 0s
CI / release-manifest (push) Canceled after 0s

This commit is contained in:
joshpatra committed 2026-09-23 12:59:40 -04:00
1 parent 0b8682e681
commit 881ad1647b
7 files changed
+373 -29

No files matched your search

@@ -99,6 +99,36 @@ public sealed class TrackIdentityServiceTests : IAsyncLifetime
Assert.All(aliases, alias => Assert.Equal(recording.Recording.Id, alias.CanonicalEntityId));
}
[Fact]
public async Task BatchResolution_PrefersAuthorizedAccountScopeWithoutLeakingOtherAccounts()
{
var actorA = Actor(_tenantA, _userA);
var actorB = Actor(_tenantA, _userB);
var accountA = await SeedUserAccount("deezer", _tenantA, _userA);
var accountB = await SeedUserAccount("deezer", _tenantA, _userB);
var catalogRecording = (await _service.CreateRecordingAsync(actorA, "catalog-recording")).Recording.Id;
var accountRecording = (await _service.CreateRecordingAsync(actorA, "account-recording")).Recording.Id;
var catalogContext = Context(actorA, "deezer");
var contextA = Context(actorA, "deezer", accountA);
var contextB = Context(actorB, "deezer", accountB);
await Link(catalogRecording, catalogContext, "shared-track");
await Link(accountRecording, contextA, "shared-track", ProviderIdentityScope.Account);
var forA = await _service.ResolveManyAsync([
new(contextA, Track("deezer", "shared-track")),
new(contextA, Track("deezer", "missing-track"))]);
var forB = await _service.ResolveManyAsync([
new(contextB, Track("deezer", "shared-track"))]);
Assert.Equal(accountRecording, forA[0]?.CanonicalRecordingId);
Assert.Null(forA[1]);
Assert.Equal(catalogRecording, forB[0]?.CanonicalRecordingId);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
_service.ResolveManyAsync([
new(contextA, Track("deezer", "shared-track")),
new(contextB, Track("deezer", "shared-track"))]));
}
[Fact]
public async Task RecordingWithMusicBrainzIdentity_QueuesIdempotentCatalogDiscovery()
{
@@ -1,10 +1,12 @@
using System.Net;
using allstarr.Core.Capabilities;
using allstarr.Core.Identity;
using allstarr.Core.Matching;
using allstarr.Core.Protocols;
using allstarr.Core.Routing;
using allstarr.Core.Storage;
using allstarr.Services;
using Microsoft.Extensions.Configuration;
using Moq;
namespace allstarr.Tests;
@@ -240,6 +242,146 @@ public sealed partial class ProtocolProviderStreamingGatewayTests
router.VerifyAll();
}
[Fact]
public async Task MetadataSearch_ProviderAndCategoryFailuresPreserveSuccessfulSongs()
{
var failed = new Mock<IProviderMetadataCapability>(MockBehavior.Strict);
failed.SetupGet(item => item.ProviderId).Returns("apple-download");
failed.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
failed.Setup(item => item.SearchTracksAsync(
It.IsAny<ProviderExecutionContext>(),
It.IsAny<ProviderMetadataSearchRequest>()))
.ThrowsAsync(new HttpRequestException("unavailable"));
failed.Setup(item => item.SearchAlbumsAsync(
It.IsAny<ProviderExecutionContext>(),
It.IsAny<ProviderMetadataSearchRequest>()))
.ThrowsAsync(new TimeoutException());
failed.Setup(item => item.SearchArtistsAsync(
It.IsAny<ProviderExecutionContext>(),
It.IsAny<ProviderMetadataSearchRequest>()))
.ThrowsAsync(new TimeoutException());
var healthy = new Mock<IProviderMetadataCapability>(MockBehavior.Strict);
healthy.SetupGet(item => item.ProviderId).Returns("deezer");
healthy.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
healthy.Setup(item => item.SearchTracksAsync(
It.IsAny<ProviderExecutionContext>(),
It.IsAny<ProviderMetadataSearchRequest>()))
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderTrackMetadata>>.Success(new(
"deezer", [new ProviderTrackMetadata(
new("deezer", ProviderResourceKind.Track, "track-1"),
"Track", [new("Artist")])])));
healthy.Setup(item => item.SearchAlbumsAsync(
It.IsAny<ProviderExecutionContext>(),
It.IsAny<ProviderMetadataSearchRequest>()))
.ThrowsAsync(new TimeoutException());
healthy.Setup(item => item.SearchArtistsAsync(
It.IsAny<ProviderExecutionContext>(),
It.IsAny<ProviderMetadataSearchRequest>()))
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
new("deezer", [])));
var registry = MetadataRegistry(failed.Object, healthy.Object);
var streaming = new Mock<IProviderStreamingCapability>(MockBehavior.Strict);
streaming.SetupGet(item => item.ProviderId).Returns("deezer");
streaming.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Streaming);
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
It.IsAny<ProviderRouteRequest>()))
.ReturnsAsync((ProviderRouteRequest request) =>
Plan(request, registry, streaming.Object));
router.Setup(item => item.PlanAsync<IProviderMetadataCapability>(
It.IsAny<ProviderRouteRequest>()))
.ReturnsAsync((ProviderRouteRequest request) =>
MetadataPlan(request, registry, failed.Object, healthy.Object));
var gateway = new ProtocolProviderGateway(
router.Object, registry, Mock.Of<IProviderRouteAccountResolver>(),
Mock.Of<IMusicMetadataService>(), new HttpClientFactory());
var song = Assert.Single((await gateway.SearchAsync(Context(), "Track", 10, 10, 10)).Songs);
Assert.Equal("track-1", song.ExternalId);
failed.VerifyAll();
healthy.VerifyAll();
}
[Theory]
[InlineData(ProviderIdentityVerification.Verified, "automatic-match", 2, "apple-download", null)]
[InlineData(ProviderIdentityVerification.Verified, "automatic-match", 2, "deezer", "deezer,apple-download,qobuz")]
[InlineData(ProviderIdentityVerification.Verified, "automatic-suggestion", 3, null, null)]
[InlineData(ProviderIdentityVerification.Pinned, "manual", 3, null, null)]
public async Task MetadataSearch_CollapsesOnlyVerifiedRoutes(
ProviderIdentityVerification verification, string method,
int expectedCount, string? preferredProvider, string? streamingOrder)
{
var providerIds = new[] { "apple-download", "deezer", "qobuz" };
var metadata = providerIds.Select(providerId =>
{
var capability = new Mock<IProviderMetadataCapability>();
capability.SetupGet(item => item.ProviderId).Returns(providerId);
capability.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
capability.Setup(item => item.SearchTracksAsync(
It.IsAny<ProviderExecutionContext>(),
It.IsAny<ProviderMetadataSearchRequest>()))
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderTrackMetadata>>.Success(new(
providerId, [new ProviderTrackMetadata(
new(providerId, ProviderResourceKind.Track, $"{providerId}-track"),
"Shared title", [new("Artist")])])));
capability.Setup(item => item.SearchAlbumsAsync(
It.IsAny<ProviderExecutionContext>(),
It.IsAny<ProviderMetadataSearchRequest>()))
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderAlbumMetadata>>.Success(
new(providerId, [])));
capability.Setup(item => item.SearchArtistsAsync(
It.IsAny<ProviderExecutionContext>(),
It.IsAny<ProviderMetadataSearchRequest>()))
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
new(providerId, [])));
return capability.Object;
}).ToArray();
var registry = MetadataRegistry(metadata);
var streaming = providerIds.Select(providerId =>
{
var capability = new Mock<IProviderStreamingCapability>();
capability.SetupGet(item => item.ProviderId).Returns(providerId);
capability.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Streaming);
return capability.Object;
}).ToArray();
var router = new Mock<IProviderRouter>();
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(
It.IsAny<ProviderRouteRequest>()))
.ReturnsAsync((ProviderRouteRequest request) => Plan(request, registry, streaming));
router.Setup(item => item.PlanAsync<IProviderMetadataCapability>(
It.IsAny<ProviderRouteRequest>()))
.ReturnsAsync((ProviderRouteRequest request) => MetadataPlan(request, registry, metadata));
var recordingId = Guid.CreateVersion7();
var identities = new Mock<ITrackIdentityService>();
identities.Setup(item => item.ResolveManyAsync(
It.IsAny<IReadOnlyList<TrackIdentityLookup>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IReadOnlyList<TrackIdentityLookup> lookups, CancellationToken _) =>
(IReadOnlyList<TrackIdentityResolution?>)lookups.Select(lookup =>
lookup.ExternalId.ProviderId == "qobuz"
? null
: new TrackIdentityResolution(
recordingId, Guid.CreateVersion7(), lookup.ExternalId,
ProviderIdentityScope.Catalog, null,
verification, method, 1))
.ToArray());
var configuration = new ConfigurationBuilder().AddInMemoryCollection(
new Dictionary<string, string?> { ["Providers:StreamingOrder"] = streamingOrder }).Build();
var gateway = new ProtocolProviderGateway(
router.Object, registry, Mock.Of<IProviderRouteAccountResolver>(),
Mock.Of<IMusicMetadataService>(), new HttpClientFactory(), configuration,
identities: identities.Object);
var songs = (await gateway.SearchAsync(Context(), "Shared title", 10, 0, 0)).Songs;
Assert.Equal(expectedCount, songs.Count);
Assert.Contains(songs, song => song.ExternalProvider == "qobuz");
if (preferredProvider != null) Assert.Contains(songs, song => song.ExternalProvider == preferredProvider);
identities.VerifyAll();
}
[Fact]
public async Task MetadataRelationships_UseOnlyUniqueExactIdsFromTheSameProvider()
{
@@ -557,6 +557,10 @@ public sealed class ProviderRouterTests
ProviderExternalResourceId externalId,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<IReadOnlyList<TrackIdentityResolution?>> ResolveManyAsync(
IReadOnlyList<TrackIdentityLookup> lookups,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<TrackIdentityTranslationResult> TranslateAsync(
ProviderExecutionContext sourceContext,
ProviderExternalResourceId sourceId,
@@ -90,6 +90,10 @@ public sealed record TrackIdentityTranslationResult(
TrackIdentityResolution? Source,
TrackIdentityResolution? Target);
public sealed record TrackIdentityLookup(
ProviderExecutionContext Context,
ProviderExternalResourceId ExternalId);
public interface ITrackIdentityService
{
Task<CanonicalRecordingCreationResult> CreateRecordingAsync(
@@ -109,6 +113,10 @@ public interface ITrackIdentityService
ProviderExternalResourceId externalId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<TrackIdentityResolution?>> ResolveManyAsync(
IReadOnlyList<TrackIdentityLookup> lookups,
CancellationToken cancellationToken = default);
Task<TrackIdentityTranslationResult> TranslateAsync(
ProviderExecutionContext sourceContext,
ProviderExternalResourceId sourceId,
@@ -418,6 +426,77 @@ public sealed class TrackIdentityService : ITrackIdentityService
cancellationToken);
}
public async Task<IReadOnlyList<TrackIdentityResolution?>> ResolveManyAsync(
IReadOnlyList<TrackIdentityLookup> lookups,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(lookups);
if (lookups.Count == 0) return [];
EnsureStorageReady();
cancellationToken.ThrowIfCancellationRequested();
ArgumentNullException.ThrowIfNull(lookups[0].Context);
var actor = lookups[0].Context.Actor;
foreach (var lookup in lookups)
{
ArgumentNullException.ThrowIfNull(lookup.Context);
ArgumentNullException.ThrowIfNull(lookup.ExternalId);
RequireSameActor(actor, lookup.Context.Actor);
RequireTrack(lookup.ExternalId);
lookup.ExternalId.RequireOwner(lookup.Context.ProviderId, ProviderResourceKind.Track);
ThrowIfUnavailable(lookup.Context, cancellationToken);
}
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
var accountIds = new Dictionary<ProviderExecutionContext, Guid?>();
foreach (var execution in lookups.Select(item => item.Context).Distinct())
{
accountIds[execution] = (await ValidateExecutionContextAsync(
context, execution, cancellationToken))?.Id;
}
var keys = lookups.Select(item => ExactKey(item.ExternalId)).ToArray();
var providers = keys.Select(item => item.ProviderId).Distinct().ToArray();
var catalogs = keys.Select(item => item.Catalog).Distinct().ToArray();
var hashes = keys.Select(item => item.ExternalIdHash).Distinct().ToArray();
var candidates = await context.ProviderTrackIdentities.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId &&
item.ResourceKind == ProviderResourceKind.Track &&
providers.Contains(item.ProviderId) &&
catalogs.Contains(item.CatalogNamespace) &&
hashes.Contains(item.ExternalIdHash))
.ToListAsync(cancellationToken);
var results = new TrackIdentityResolution?[lookups.Count];
for (var index = 0; index < lookups.Count; index++)
{
var lookup = lookups[index];
var key = keys[index];
var accountId = accountIds[lookup.Context];
var exact = candidates.Where(item =>
item.ProviderId == key.ProviderId &&
item.CatalogNamespace == key.Catalog &&
item.ExternalIdHash == key.ExternalIdHash &&
(item.Scope == ProviderIdentityScope.Catalog ||
item.Scope == ProviderIdentityScope.Account &&
item.ProviderAccountId == accountId)).ToArray();
foreach (var candidate in exact)
{
EnsureExactExternalId(candidate, lookup.ExternalId.Value);
}
var preferred = PreferAccountScope(exact, accountId);
if (preferred.Count > 1)
{
throw new InvalidOperationException(
"More than one accepted track identity exists in the same exact scope.");
}
if (preferred.Count == 1) results[index] = ToResolution(preferred[0]);
}
return results;
}
public async Task<TrackIdentityTranslationResult> TranslateAsync(
ProviderExecutionContext sourceContext,
ProviderExternalResourceId sourceId,
@@ -4,7 +4,9 @@ using System.Security.Cryptography;
using System.Text;
using System.Collections.Immutable;
using allstarr.Core.Capabilities;
using allstarr.Core.Matching;
using allstarr.Core.Routing;
using allstarr.Core.Storage;
using allstarr.Core.Settings;
using allstarr.Core.Downloads;
using allstarr.Models.Domain;
@@ -114,7 +116,8 @@ public sealed class ProtocolProviderGateway(
ILogger<ProtocolProviderGateway>? logger = null,
ManagedTrackCacheService? managedTrackCache = null,
PlaybackDeliveryActivityStore? playbackActivity = null,
IEffectiveProviderPolicyResolver? effectivePolicies = null) : IProtocolProviderGateway
IEffectiveProviderPolicyResolver? effectivePolicies = null,
ITrackIdentityService? identities = null) : IProtocolProviderGateway
{
private const string StreamingClientName = "ProtocolProviderStreaming";
private const int ProviderSearchConcurrency = 4;
@@ -160,13 +163,13 @@ public sealed class ProtocolProviderGateway(
var effectivePolicy = effectivePolicies == null
? null
: await effectivePolicies.ResolveAsync(actor.TenantId, protocol.CancellationToken);
var playableProviders = songLimit > 0
? (await ResolvePlayableProviderOrderAsync(
protocol,
actor,
ResolveProviderOrder(ProviderCapabilityKind.Streaming, effectivePolicy)))
.ToHashSet(StringComparer.Ordinal)
var playableOrder = songLimit > 0
? await ResolvePlayableProviderOrderAsync(
protocol,
actor,
ResolveProviderOrder(ProviderCapabilityKind.Streaming, effectivePolicy))
: [];
var playableProviders = playableOrder.ToHashSet(StringComparer.Ordinal);
var fetchLimit = Math.Clamp(Math.Max(songLimit, Math.Max(albumLimit, artistLimit)), 1, 200);
var providerOrder = ResolveProviderOrder(ProviderCapabilityKind.Metadata, effectivePolicy)
.Where(item => requestedProviderId == null || item == requestedProviderId)
@@ -181,6 +184,7 @@ public sealed class ProtocolProviderGateway(
sourceTrackId: null));
var routed = new SearchResult();
var trackLookups = new List<(Song Song, TrackIdentityLookup Lookup)>();
using var metadataSearchGate = new SemaphoreSlim(ProviderSearchConcurrency);
var searchTasks = plan.Candidates.Select(async candidate =>
{
@@ -188,13 +192,20 @@ public sealed class ProtocolProviderGateway(
try
{
var request = new ProviderMetadataSearchRequest(query, new ProviderPageRequest(fetchLimit));
var songsTask = candidate.Implementation.SearchTracksAsync(candidate.Context, request);
var albumsTask = candidate.Implementation.SearchAlbumsAsync(candidate.Context, request);
var artistsTask = candidate.Implementation.SearchArtistsAsync(candidate.Context, request);
var songsTask = TrySearchAsync(
() => candidate.Implementation.SearchTracksAsync(candidate.Context, request),
protocol.CancellationToken);
var albumsTask = TrySearchAsync(
() => candidate.Implementation.SearchAlbumsAsync(candidate.Context, request),
protocol.CancellationToken);
var artistsTask = TrySearchAsync(
() => candidate.Implementation.SearchArtistsAsync(candidate.Context, request),
protocol.CancellationToken);
await Task.WhenAll(songsTask, albumsTask, artistsTask);
return new
{
ProviderId = NormalizeProvider(candidate.Provider.Id),
Context = candidate.Context,
SongsResult = await songsTask,
AlbumsResult = await albumsTask,
ArtistsResult = await artistsTask
@@ -210,35 +221,115 @@ public sealed class ProtocolProviderGateway(
foreach (var outcome in searchOutcomes)
{
var albums = outcome.AlbumsResult.IsSuccess
var albums = outcome.AlbumsResult?.IsSuccess == true
? outcome.AlbumsResult.RequireValue().Items
: [];
var artists = outcome.ArtistsResult.IsSuccess
var artists = outcome.ArtistsResult?.IsSuccess == true
? outcome.ArtistsResult.RequireValue().Items
: [];
if (playableProviders.Contains(outcome.ProviderId) && outcome.SongsResult.IsSuccess)
if (playableProviders.Contains(outcome.ProviderId) && outcome.SongsResult?.IsSuccess == true)
{
routed.Songs.AddRange(outcome.SongsResult.RequireValue().Items
.Select(item => EnrichRelationships(Map(item), albums, artists)));
foreach (var item in outcome.SongsResult.RequireValue().Items)
{
var song = EnrichRelationships(Map(item), albums, artists);
routed.Songs.Add(song);
trackLookups.Add((song, new TrackIdentityLookup(outcome.Context, item.Id)));
}
}
if (outcome.AlbumsResult.IsSuccess)
if (outcome.AlbumsResult?.IsSuccess == true)
{
routed.Albums.AddRange(outcome.AlbumsResult.RequireValue().Items.Select(Map));
}
if (outcome.ArtistsResult.IsSuccess)
if (outcome.ArtistsResult?.IsSuccess == true)
{
routed.Artists.AddRange(outcome.ArtistsResult.RequireValue().Items.Select(Map));
}
}
var songs = await CollapseVerifiedSearchTracksAsync(
routed.Songs, trackLookups, playableOrder, protocol.CancellationToken);
return new SearchResult
{
Songs = Merge(routed.Songs, [], songLimit, item => Key(item.ExternalProvider, item.ExternalId, item.Id), item => item.ExternalProvider),
Songs = Merge(songs, [], songLimit, item => Key(item.ExternalProvider, item.ExternalId, item.Id), item => item.ExternalProvider, playableOrder),
Albums = Merge(routed.Albums, [], albumLimit, item => Key(item.ExternalProvider, item.ExternalId, item.Id), item => item.ExternalProvider),
Artists = Merge(routed.Artists, [], artistLimit, item => Key(item.ExternalProvider, item.ExternalId, item.Id), item => item.ExternalProvider)
};
}
private async Task<IReadOnlyList<Song>> CollapseVerifiedSearchTracksAsync(
IReadOnlyList<Song> songs,
IReadOnlyList<(Song Song, TrackIdentityLookup Lookup)> trackLookups,
IReadOnlyList<string> providerOrder,
CancellationToken cancellationToken)
{
if (identities == null || trackLookups.Select(item => item.Lookup.Context.ProviderId).Distinct().Count() < 2)
{
return songs;
}
IReadOnlyList<TrackIdentityResolution?> resolved;
try
{
resolved = await identities.ResolveManyAsync(
trackLookups.Select(item => item.Lookup).ToArray(), cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
logger?.LogDebug("Verified search identity lookup failed; preserving provider results");
return songs;
}
var verified = trackLookups.Select((entry, index) => (entry.Song, Identity: resolved[index]))
.Where(item => item.Identity?.Verification == ProviderIdentityVerification.Verified &&
item.Identity.VerificationMethod is not (
"automatic-suggestion" or
ManualTrackAuthorityPolicy.ReleasedProviderVerificationMethod or
ManualTrackAuthorityPolicy.ReplacedProviderVerificationMethod))
.ToArray();
var bestByRecording = verified
.GroupBy(item => item.Identity!.CanonicalRecordingId)
.ToDictionary(
group => group.Key,
group => group.OrderBy(item =>
ProviderPriority(providerOrder, item.Song.ExternalProvider)).First().Song);
var selected = new HashSet<Song>(bestByRecording.Values);
var eligible = new HashSet<Song>(verified.Select(item => item.Song));
return songs
.Where(song => !eligible.Contains(song) || selected.Contains(song))
.ToArray();
}
private static int ProviderPriority(IReadOnlyList<string> order, string? provider)
{
for (var index = 0; index < order.Count; index++)
{
if (order[index] == provider) return index;
}
return int.MaxValue;
}
private static async Task<ProviderOutcome<ProviderPage<T>>?> TrySearchAsync<T>(
Func<Task<ProviderOutcome<ProviderPage<T>>>> search,
CancellationToken cancellationToken)
{
try
{
return await search();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
return null;
}
}
public async Task<IReadOnlyList<Song>> SearchPlayableSongsAsync(
ProtocolExecutionContext protocol,
string query,
@@ -293,17 +384,11 @@ public sealed class ProtocolProviderGateway(
await searchGate.WaitAsync(protocol.CancellationToken);
try
{
return await candidate.Implementation.SearchTracksAsync(
candidate.Context,
new ProviderMetadataSearchRequest(query, new ProviderPageRequest(limit)));
}
catch (OperationCanceledException) when (protocol.CancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
return await TrySearchAsync(
() => candidate.Implementation.SearchTracksAsync(
candidate.Context,
new ProviderMetadataSearchRequest(query, new ProviderPageRequest(limit))),
protocol.CancellationToken);
}
finally
{
+2
View File
@@ -81,6 +81,8 @@ Allstarr changes a native response only when a documented feature requires it: e
`TrackIdentityService`, backend library indexing, persisted provider routes, and the playlist orchestration layer are the shared path. Accepted decisions are reusable by automatic matching, interactive matching, synchronization, playback, and event projections. Candidates that satisfy confidence and artist-evidence requirements are selected by local-first/configured streaming priority, not relative confidence windows. Only tentative selection retains preference windows. Cached provider reuse passes through the same decision engine with current local candidates and rejections; it does not force acceptance or overwrite confidence. Matching-algorithm changes enqueue owner-scoped `track-match.rematch-all` jobs that replace stale automatic decisions in bounded batches while preserving manual authority and append-only history. Playlist refresh and materialization run through durable playlist links and the `playlist.materialize` job; there is no provider-specific matching coordinator. A one-time import reuses its first published source snapshot and no longer requires the source account for later projection or rebuilds. Keep-all retention fans resolved external routes into idempotent `playlist.retain-track` jobs, reauthorizes download accounts for the exact owner and library at execution time, and publishes verified files through the managed-file owner.
Authenticated search keeps successful tracks, albums, and artists when another provider or search category fails. A batched, account-aware identity lookup collapses external track hits only when accepted links identify the same recording; tentative, released, replaced, pinned, and unknown links remain separate. The representative follows configured streaming order. This is still a provider-shaped search result, not the stable canonical protocol ID or native-item merge required by the [unified music service plan](unified-music-service-plan.md).
## Canonical catalog ingestion
`MusicBrainzService` is the single bounded client for MusicBrainz-compatible
@@ -348,6 +348,8 @@ Checkpoint as of 2026-09-22; the alias projection changes are not yet deployed:
### Stage 3: Replace provider-shaped search with catalog search
Current increment: authenticated search isolates provider/category failures and collapses external results with the same accepted, account-authorized recording link. It retains existing provider IDs for client compatibility and leaves unknown, tentative, and pinned results separate. This does not satisfy the Stage 3 exit condition: stable canonical protocol IDs, native representative merging, and coherent artist/release browse are still pending. Do not describe the unified catalog as shipped or remove legacy aliases on the strength of this increment.
- Query canonical projections and overlay user-authorized route availability.
- Return stable Allstarr IDs and resolve legacy aliases.
- Implement coherent artist, release, track, and discography browse.