mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 13:55:39 -04:00
fix(catalog): enrich native identities without losing mappings
This commit is contained in:
6 files changed
+386
-104
No files matched your search
@@ -5,7 +5,10 @@ using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
@@ -56,6 +59,19 @@ public sealed class TrackIdentityServiceTests : IAsyncLifetime
|
||||
_service = new TrackIdentityService(_factory, _storageState, _clock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledCatalog_CreatesIdentityWithoutEnqueuingRemoteWork()
|
||||
{
|
||||
var queue = new Mock<IMusicBrainzCatalogRefreshQueue>(MockBehavior.Strict);
|
||||
var service = new TrackIdentityService(_factory, _storageState, _clock, queue.Object,
|
||||
Options.Create(new MusicBrainzSettings { Enabled = false }));
|
||||
var created = await service.CreateRecordingAsync(Actor(_tenantA, _userA), "disabled-catalog",
|
||||
musicBrainzRecordingId: "16ba7915-2acf-42b2-8c87-ed67090dca91");
|
||||
Assert.True(created.Created);
|
||||
Assert.NotNull(created.Recording.MusicBrainzRecordingId);
|
||||
queue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneCanonicalRecording_LinksManyProvidersAndTranslatesExactly()
|
||||
{
|
||||
@@ -158,6 +174,129 @@ public sealed class TrackIdentityServiceTests : IAsyncLifetime
|
||||
It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task AdditionalExactSignal_EnrichesSameRecordingAndPreservesPinnedRoutes(bool isrcFirst)
|
||||
{
|
||||
const string isrc = "USRC17607839";
|
||||
const string mbid = "11111111-1111-4111-8111-111111111111";
|
||||
var actor = Actor(_tenantA, _userA);
|
||||
var first = await _service.CreateRecordingAsync(actor, "initial-signal",
|
||||
isrcFirst ? isrc : null, isrcFirst ? null : mbid);
|
||||
var link = await _service.LinkAsync(Context(actor, "deezer"), new(
|
||||
first.Recording.Id, Track("deezer", "pinned-track"), ProviderIdentityScope.Catalog,
|
||||
ProviderIdentityVerification.Pinned, "manual-review", 1));
|
||||
var queue = new Mock<IMusicBrainzCatalogRefreshQueue>(MockBehavior.Strict);
|
||||
queue.Setup(item => item.EnqueueRecordingAsync(actor, mbid, "enrich", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DurableJobEnqueueResult(Guid.CreateVersion7(), true));
|
||||
var service = new TrackIdentityService(_factory, _storageState, _clock, queue.Object);
|
||||
|
||||
var enriched = await service.CreateRecordingAsync(actor, "enrich", isrc, mbid);
|
||||
var repeated = await service.CreateRecordingAsync(actor, "enrich", isrc, mbid);
|
||||
|
||||
Assert.False(enriched.Created);
|
||||
Assert.Equal(first.Recording.Id, enriched.Recording.Id);
|
||||
Assert.Equal(isrc, enriched.Recording.Isrc);
|
||||
Assert.Equal(mbid, enriched.Recording.MusicBrainzRecordingId);
|
||||
Assert.Equal(first.Recording.Revision + 1, enriched.Recording.Revision);
|
||||
Assert.Equal(enriched.Recording, repeated.Recording);
|
||||
queue.Verify(item => item.EnqueueRecordingAsync(actor, mbid, "enrich", It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
await using var db = await _factory.CreateDbContextAsync();
|
||||
Assert.False((await db.CanonicalRecordings.SingleAsync()).IsProvisional);
|
||||
var pinned = await db.ProviderTrackIdentities.SingleAsync();
|
||||
Assert.Equal(link.LinkId, pinned.Id);
|
||||
Assert.Equal(first.Recording.Id, pinned.CanonicalRecordingId);
|
||||
Assert.Equal(ProviderIdentityVerification.Pinned, pinned.Verification);
|
||||
Assert.Equal(3, await db.CanonicalCatalogAliases.CountAsync());
|
||||
Assert.Single(await db.AuditEvents.Where(item => item.Outcome == "enriched").ToArrayAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdditionalExactSignal_AliasConflictDoesNotPartiallyEnrichRecording()
|
||||
{
|
||||
const string isrc = "USRC17607839";
|
||||
const string mbid = "11111111-1111-4111-8111-111111111111";
|
||||
var actor = Actor(_tenantA, _userA);
|
||||
var first = await _service.CreateRecordingAsync(actor, "first", isrc);
|
||||
var other = await _service.CreateRecordingAsync(actor, "other");
|
||||
await using (var db = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
db.CanonicalCatalogAliases.Add(new CanonicalCatalogAliasRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = _tenantA,
|
||||
EntityKind = CanonicalCatalogEntityKind.Recording,
|
||||
CanonicalEntityId = other.Recording.Id,
|
||||
Namespace = "musicbrainz",
|
||||
ExternalId = mbid,
|
||||
ExternalIdHash = CanonicalCatalogKeys.Hash(mbid),
|
||||
CreatedAt = _clock.UtcNow,
|
||||
LastSeenAt = _clock.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.CreateRecordingAsync(actor, "conflicting-alias", isrc, mbid));
|
||||
|
||||
await using var verification = await _factory.CreateDbContextAsync();
|
||||
var unchanged = await verification.CanonicalRecordings.SingleAsync(item => item.Id == first.Recording.Id);
|
||||
Assert.Null(unchanged.MusicBrainzRecordingId);
|
||||
Assert.True(unchanged.IsProvisional);
|
||||
Assert.Equal(first.Recording.Revision, unchanged.Revision);
|
||||
Assert.Empty(await verification.AuditEvents.Where(item => item.Outcome == "enriched").ToArrayAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(true, true)]
|
||||
public async Task ConcurrentExactSignals_CoalesceCompatibleWritesAndRejectConflicts(bool existing, bool conflicting)
|
||||
{
|
||||
const string isrc = "USRC17607839";
|
||||
const string mbid = "11111111-1111-4111-8111-111111111111";
|
||||
var actor = Actor(_tenantA, _userA);
|
||||
var original = existing ? await _service.CreateRecordingAsync(actor, "seed", isrc) : null;
|
||||
var options = new DbContextOptionsBuilder<AllstarrDbContext>(_database.Options)
|
||||
.AddInterceptors(new ConcurrentSaveGate()).Options;
|
||||
var service = new TrackIdentityService(new TestDbContextFactory(options), _storageState, _clock);
|
||||
async Task<CanonicalRecordingCreationResult?> Create(string recordingMbid)
|
||||
{
|
||||
try { return await service.CreateRecordingAsync(actor, "concurrent", isrc, recordingMbid); }
|
||||
catch (InvalidOperationException) { return null; }
|
||||
}
|
||||
|
||||
var results = await Task.WhenAll(Create(mbid), Create(conflicting
|
||||
? "22222222-2222-4222-8222-222222222222" : mbid));
|
||||
|
||||
var succeeded = results.OfType<CanonicalRecordingCreationResult>().ToArray();
|
||||
Assert.Equal(conflicting ? 1 : 2, succeeded.Length);
|
||||
await using var db = await _factory.CreateDbContextAsync();
|
||||
var recording = await db.CanonicalRecordings.SingleAsync();
|
||||
Assert.All(succeeded, item => Assert.Equal(recording.Id, item.Recording.Id));
|
||||
if (original != null) Assert.Equal(original.Recording.Id, recording.Id);
|
||||
Assert.Equal(2, await db.CanonicalCatalogAliases.CountAsync());
|
||||
Assert.All(await db.CanonicalCatalogAliases.ToArrayAsync(), item => Assert.Equal(recording.Id, item.CanonicalEntityId));
|
||||
Assert.Equal(existing ? 1 : 0, await db.AuditEvents.CountAsync(item => item.Outcome == "enriched"));
|
||||
}
|
||||
|
||||
private sealed class ConcurrentSaveGate : SaveChangesInterceptor
|
||||
{
|
||||
private int _arrivals;
|
||||
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var arrival = Interlocked.Increment(ref _arrivals);
|
||||
if (arrival == 2) _ready.TrySetResult();
|
||||
if (arrival <= 2) await _ready.Task.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingVerifiedLink_RemainsUnresolvedAndNeverGuesses()
|
||||
{
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using allstarr.Core.Identity;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Core.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
@@ -18,6 +21,8 @@ public sealed class LibraryIndexServiceTests : IAsyncLifetime
|
||||
private Guid _identityA;
|
||||
private Guid _identityB;
|
||||
private FakeClock _clock = null!;
|
||||
private TrackIdentityService _identities = null!;
|
||||
private readonly Mock<IMusicBrainzCatalogRefreshQueue> _catalogQueue = new(MockBehavior.Strict);
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
@@ -53,7 +58,8 @@ public sealed class LibraryIndexServiceTests : IAsyncLifetime
|
||||
var state = new DurableStorageState(options);
|
||||
state.Set(DurableStorageReadiness.Ready, "fixture");
|
||||
_clock = new FakeClock(now);
|
||||
_service = new LibraryIndexService(_factory, state, _clock);
|
||||
_identities = new TrackIdentityService(_factory, state, _clock, _catalogQueue.Object);
|
||||
_service = new LibraryIndexService(_factory, state, _clock, _identities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,6 +138,11 @@ public sealed class LibraryIndexServiceTests : IAsyncLifetime
|
||||
};
|
||||
await _service.UpsertAsync(Context(_userA, "principal-a", "music"), input);
|
||||
await _service.UpsertAsync(Context(_userB, "principal-b", "music"), input);
|
||||
var rescanned = await _service.UpsertAsync(Context(_userA, "principal-a", "music"), Input() with
|
||||
{
|
||||
MusicBrainzRecordingId = "16ba7915-2acf-42b2-8c87-ed67090dca91"
|
||||
});
|
||||
Assert.Equal(canonicalId, rescanned.CanonicalRecordingId);
|
||||
|
||||
await using var verification = await _factory.CreateDbContextAsync();
|
||||
Assert.Equal(2, await verification.LibraryTracks.CountAsync());
|
||||
@@ -141,6 +152,106 @@ public sealed class LibraryIndexServiceTests : IAsyncLifetime
|
||||
alias.Namespace);
|
||||
Assert.Equal("local-item", alias.ExternalId);
|
||||
Assert.Equal(canonicalId, alias.CanonicalEntityId);
|
||||
Assert.All(await verification.LibraryTracks.ToListAsync(), track => Assert.Equal(1, track.AcceptedDecisionVersion));
|
||||
Assert.Null((await verification.CanonicalRecordings.FindAsync(canonicalId))!.MusicBrainzRecordingId);
|
||||
_catalogQueue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeAlias_RecoversAssignmentLostByLegacyRescan()
|
||||
{
|
||||
var context = Context(_userA, "principal-a", "music");
|
||||
var canonical = await _identities.CreateRecordingAsync(context.RequireActor(), "existing-native");
|
||||
var indexed = await _service.UpsertAsync(context, Input() with { CanonicalRecordingId = canonical.Recording.Id });
|
||||
await using (var db = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
var track = await db.LibraryTracks.FindAsync(indexed.Id);
|
||||
track!.CanonicalRecordingId = null;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var rescanned = await _service.UpsertAsync(context, Input());
|
||||
Assert.Equal(canonical.Recording.Id, rescanned.CanonicalRecordingId);
|
||||
await using var verification = await _factory.CreateDbContextAsync();
|
||||
Assert.Single(await verification.CanonicalRecordings.ToListAsync());
|
||||
Assert.Single(await verification.CanonicalCatalogAliases.ToListAsync());
|
||||
_catalogQueue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeRecording_EnrichesProviderIdentityAndQueuesCatalogWithoutChangingNativeMetadata()
|
||||
{
|
||||
const string mbid = "16ba7915-2acf-42b2-8c87-ed67090dca91";
|
||||
var context = Context(_userA, "principal-a", "music");
|
||||
var original = await _identities.CreateRecordingAsync(context.RequireActor(), "provider-first", Input().Isrc);
|
||||
_catalogQueue.Setup(queue => queue.EnqueueRecordingAsync(
|
||||
It.Is<ProviderActorContext>(actor => actor.TenantId == _tenantId && actor.UserId == _userA),
|
||||
mbid, context.CorrelationId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DurableJobEnqueueResult(Guid.CreateVersion7(), true));
|
||||
|
||||
var indexed = await _service.UpsertAsync(context, Input() with { MusicBrainzRecordingId = mbid });
|
||||
var rescanned = await _service.UpsertAsync(context, Input() with { MusicBrainzRecordingId = mbid });
|
||||
Assert.Equal(original.Recording.Id, indexed.CanonicalRecordingId);
|
||||
Assert.Equal(indexed.CanonicalRecordingId, rescanned.CanonicalRecordingId);
|
||||
Assert.Equal(Input().BackendItemId, indexed.BackendItemId);
|
||||
Assert.Equal(Input().Title, indexed.Title);
|
||||
Assert.Equal(Input().FilePath, indexed.FilePath);
|
||||
await using var db = await _factory.CreateDbContextAsync();
|
||||
var canonical = Assert.Single(await db.CanonicalRecordings.ToListAsync());
|
||||
Assert.Equal(mbid, canonical.MusicBrainzRecordingId);
|
||||
Assert.False(canonical.IsProvisional);
|
||||
Assert.Equal(3, await db.CanonicalCatalogAliases.CountAsync());
|
||||
Assert.All(await db.CanonicalCatalogAliases.ToListAsync(), alias => Assert.Equal(canonical.Id, alias.CanonicalEntityId));
|
||||
_catalogQueue.Verify(queue => queue.EnqueueRecordingAsync(
|
||||
It.IsAny<ProviderActorContext>(), mbid, context.CorrelationId, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeRecording_ConflictingSignalsRemainIndexedWithoutMergingOrQueuing()
|
||||
{
|
||||
var context = Context(_userA, "principal-a", "music");
|
||||
var first = await _identities.CreateRecordingAsync(context.RequireActor(), "isrc-first", Input().Isrc);
|
||||
var secondId = Guid.CreateVersion7();
|
||||
const string mbid = "16ba7915-2acf-42b2-8c87-ed67090dca91";
|
||||
await using (var db = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
db.CanonicalRecordings.Add(new CanonicalRecordingRecord
|
||||
{
|
||||
Id = secondId,
|
||||
TenantId = _tenantId,
|
||||
CreatedByUserId = _userA,
|
||||
MusicBrainzRecordingId = mbid,
|
||||
CreatedAt = _clock.UtcNow,
|
||||
UpdatedAt = _clock.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var indexed = await _service.UpsertAsync(context, Input() with { MusicBrainzRecordingId = mbid });
|
||||
Assert.Null(indexed.CanonicalRecordingId);
|
||||
Assert.Equal(mbid, indexed.MusicBrainzRecordingId);
|
||||
Assert.Single(await _service.GetMatchCandidatesAsync(context, "music"));
|
||||
await using var verification = await _factory.CreateDbContextAsync();
|
||||
Assert.Equal(2, await verification.CanonicalRecordings.CountAsync());
|
||||
Assert.Null((await verification.CanonicalRecordings.FindAsync(first.Recording.Id))!.MusicBrainzRecordingId);
|
||||
var audit = await verification.AuditEvents.SingleAsync(item => item.Category == "library-index");
|
||||
Assert.Contains("\"canonicalEnrichment\":\"deferred\"", audit.DetailsJson);
|
||||
_catalogQueue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("not-a-mbid")]
|
||||
[InlineData("00000000-0000-0000-0000-000000000000")]
|
||||
public async Task NativeRecording_WithoutValidMbidStillIndexes(string? mbid)
|
||||
{
|
||||
var indexed = await _service.UpsertAsync(Context(_userA, "principal-a", "music"),
|
||||
Input() with { MusicBrainzRecordingId = mbid });
|
||||
Assert.Null(indexed.CanonicalRecordingId);
|
||||
await using var db = await _factory.CreateDbContextAsync();
|
||||
Assert.Empty(await db.CanonicalRecordings.ToListAsync());
|
||||
Assert.Single(await db.LibraryTracks.ToListAsync());
|
||||
_catalogQueue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Services.MusicBrainz;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
@@ -260,8 +262,10 @@ public sealed class CanonicalCatalogStorageTests : IAsyncLifetime
|
||||
Assert.Equal("fixture", facts[1].SourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrainzMashGraphIngest_IsAtomicIdempotentAndPreservesEditionIdentity()
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task BrainzMashGraphIngest_IsAtomicIdempotentAndPreservesEditionIdentity(bool enrichExisting)
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 9, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var tenantId = Guid.CreateVersion7();
|
||||
@@ -324,6 +328,17 @@ public sealed class CanonicalCatalogStorageTests : IAsyncLifetime
|
||||
var graph = new MusicBrainzCatalogGraph(release, releaseGroup, [artist]);
|
||||
var source = new MusicBrainzCatalogSource(
|
||||
"BrainzMash", "brainzmash:ws2", now, now.AddDays(7));
|
||||
Guid? originalRecordingId = null;
|
||||
if (enrichExisting)
|
||||
{
|
||||
var identities = new TrackIdentityService(new TestDbContextFactory(_database.Options),
|
||||
ReadyStorage(), Mock.Of<IPlatformClock>(item => item.UtcNow == now));
|
||||
var original = await identities.CreateRecordingAsync(actor, "provider-first", "USRC17607839");
|
||||
originalRecordingId = original.Recording.Id;
|
||||
var identified = await identities.CreateRecordingAsync(actor, "catalog-evidence", "USRC17607839",
|
||||
"55555555-5555-4555-8555-555555555555");
|
||||
Assert.Equal(originalRecordingId, identified.Recording.Id);
|
||||
}
|
||||
|
||||
var first = await service.IngestAsync(actor, graph, source);
|
||||
var repeated = await service.IngestAsync(
|
||||
@@ -333,9 +348,9 @@ public sealed class CanonicalCatalogStorageTests : IAsyncLifetime
|
||||
|
||||
Assert.Equal(first.ReleaseGroupId, repeated.ReleaseGroupId);
|
||||
Assert.Equal(first.ReleaseId, repeated.ReleaseId);
|
||||
Assert.Equal(7, first.EntitiesCreated);
|
||||
Assert.Equal(enrichExisting ? 6 : 7, first.EntitiesCreated);
|
||||
Assert.Equal(0, repeated.EntitiesCreated);
|
||||
Assert.Equal(7, first.Evidence.AliasesCreated);
|
||||
Assert.Equal(enrichExisting ? 6 : 7, first.Evidence.AliasesCreated);
|
||||
Assert.Equal(7, first.Evidence.FactsCreated);
|
||||
Assert.Equal(7, repeated.Evidence.AliasesSeen);
|
||||
Assert.Equal(0, repeated.Evidence.FactsCreated);
|
||||
@@ -348,11 +363,20 @@ public sealed class CanonicalCatalogStorageTests : IAsyncLifetime
|
||||
Assert.Single(await verification.CanonicalReleases.Where(item => item.TenantId == tenantId).ToListAsync());
|
||||
Assert.Equal(2, await verification.CanonicalRecordings.CountAsync(item => item.TenantId == tenantId));
|
||||
Assert.Equal(2, await verification.CanonicalReleaseTracks.CountAsync(item => item.TenantId == tenantId));
|
||||
Assert.Equal(7, await verification.CanonicalCatalogAliases.CountAsync(item => item.TenantId == tenantId));
|
||||
Assert.Equal(enrichExisting ? 8 : 7, await verification.CanonicalCatalogAliases.CountAsync(item => item.TenantId == tenantId));
|
||||
Assert.Equal(7, await verification.CatalogFacts.CountAsync(item => item.TenantId == tenantId));
|
||||
Assert.All(
|
||||
await verification.CanonicalRecordings.Where(item => item.TenantId == tenantId).ToListAsync(),
|
||||
item => Assert.False(item.IsProvisional));
|
||||
if (originalRecordingId.HasValue)
|
||||
{
|
||||
var preserved = await verification.CanonicalRecordings.SingleAsync(item => item.Id == originalRecordingId.Value);
|
||||
Assert.Equal("Sunroof", preserved.Title);
|
||||
Assert.Equal("USRC17607839", preserved.Isrc);
|
||||
Assert.Equal(163_000, preserved.DurationMilliseconds);
|
||||
Assert.Equal(originalRecordingId.Value, (await verification.CanonicalReleaseTracks.SingleAsync(item =>
|
||||
item.TenantId == tenantId && item.MusicBrainzTrackId == "44444444-4444-4444-8444-444444444444")).CanonicalRecordingId);
|
||||
}
|
||||
}
|
||||
|
||||
var invalidTenantId = Guid.CreateVersion7();
|
||||
|
||||
@@ -74,15 +74,18 @@ public sealed class LibraryIndexService : ILibraryIndexService
|
||||
private readonly IDbContextFactory<AllstarrDbContext> _contextFactory;
|
||||
private readonly DurableStorageState _storageState;
|
||||
private readonly IPlatformClock _clock;
|
||||
private readonly ITrackIdentityService _identities;
|
||||
|
||||
public LibraryIndexService(
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
DurableStorageState storageState,
|
||||
IPlatformClock clock)
|
||||
IPlatformClock clock,
|
||||
ITrackIdentityService identities)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_storageState = storageState;
|
||||
_clock = clock;
|
||||
_identities = identities;
|
||||
}
|
||||
|
||||
public async Task<IndexedLibraryTrack> UpsertAsync(
|
||||
@@ -141,8 +144,8 @@ public sealed class LibraryIndexService : ILibraryIndexService
|
||||
record.MusicBrainzReleaseId = NormalizeGuid(input.MusicBrainzReleaseId);
|
||||
record.MusicBrainzArtistId = NormalizeGuid(input.MusicBrainzArtistId);
|
||||
record.ProviderIdsJson = JsonSerializer.Serialize(providerIds, JsonOptions);
|
||||
record.CanonicalRecordingId = input.CanonicalRecordingId;
|
||||
record.AcceptedDecisionVersion = input.AcceptedDecisionVersion;
|
||||
record.CanonicalRecordingId = input.CanonicalRecordingId ?? record.CanonicalRecordingId;
|
||||
record.AcceptedDecisionVersion = input.AcceptedDecisionVersion ?? record.AcceptedDecisionVersion;
|
||||
record.CoverArtReference = ValidateReference(input.CoverArtReference);
|
||||
record.SourceModifiedAt = input.SourceModifiedAt;
|
||||
record.IndexedAt = now;
|
||||
@@ -152,6 +155,34 @@ public sealed class LibraryIndexService : ILibraryIndexService
|
||||
db.LibraryTracks.Add(record);
|
||||
}
|
||||
|
||||
var enrichment = "unchanged";
|
||||
if (!record.CanonicalRecordingId.HasValue)
|
||||
{
|
||||
var aliasNamespace = CanonicalCatalogKeys.NativeTrackNamespace(record.Protocol, record.BackendInstanceId);
|
||||
var itemHash = CanonicalCatalogKeys.Hash(record.BackendItemId);
|
||||
record.CanonicalRecordingId = await db.CanonicalCatalogAliases.AsNoTracking()
|
||||
.Where(alias => alias.TenantId == principal.TenantId && alias.Namespace == aliasNamespace &&
|
||||
alias.EntityKind == CanonicalCatalogEntityKind.Recording &&
|
||||
alias.ExternalIdHash == itemHash && alias.ExternalId == record.BackendItemId)
|
||||
.Select(alias => (Guid?)alias.CanonicalEntityId).SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
if (Guid.TryParse(record.MusicBrainzRecordingId, out var mbid) && mbid != Guid.Empty &&
|
||||
(!record.CanonicalRecordingId.HasValue || await db.CanonicalRecordings.AsNoTracking().AnyAsync(
|
||||
item => item.TenantId == principal.TenantId && item.Id == record.CanonicalRecordingId &&
|
||||
item.MusicBrainzRecordingId == mbid.ToString("D"), cancellationToken)))
|
||||
{
|
||||
try
|
||||
{
|
||||
var identity = await _identities.CreateRecordingAsync(
|
||||
executionContext.RequireActor(), executionContext.CorrelationId,
|
||||
record.Isrc, mbid.ToString("D"), cancellationToken);
|
||||
record.CanonicalRecordingId ??= identity.Recording.Id;
|
||||
enrichment = "linked";
|
||||
}
|
||||
catch (ArgumentException) { enrichment = "invalid-signals"; }
|
||||
catch (InvalidOperationException) { enrichment = "deferred"; }
|
||||
}
|
||||
|
||||
await CanonicalCatalogIdentityProjection.ProjectLibraryTrackAsync(
|
||||
db,
|
||||
executionContext.RequireActor(),
|
||||
@@ -173,7 +204,8 @@ public sealed class LibraryIndexService : ILibraryIndexService
|
||||
libraryTrackId = record.Id,
|
||||
libraryScopeId = input.LibraryScopeId,
|
||||
backendInstanceId = principal.BackendInstanceId,
|
||||
hasCanonicalRecording = input.CanonicalRecordingId.HasValue
|
||||
hasCanonicalRecording = record.CanonicalRecordingId.HasValue,
|
||||
canonicalEnrichment = enrichment
|
||||
}),
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ using System.Text.RegularExpressions;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Core.Matching;
|
||||
|
||||
@@ -136,17 +138,20 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
private readonly DurableStorageState _storageState;
|
||||
private readonly IPlatformClock _clock;
|
||||
private readonly IMusicBrainzCatalogRefreshQueue? _catalogRefreshQueue;
|
||||
private readonly IOptions<MusicBrainzSettings>? _catalogSettings;
|
||||
|
||||
public TrackIdentityService(
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
DurableStorageState storageState,
|
||||
IPlatformClock clock,
|
||||
IMusicBrainzCatalogRefreshQueue? catalogRefreshQueue = null)
|
||||
IMusicBrainzCatalogRefreshQueue? catalogRefreshQueue = null,
|
||||
IOptions<MusicBrainzSettings>? catalogSettings = null)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_storageState = storageState;
|
||||
_clock = clock;
|
||||
_catalogRefreshQueue = catalogRefreshQueue;
|
||||
_catalogSettings = catalogSettings;
|
||||
}
|
||||
|
||||
public async Task<CanonicalRecordingCreationResult> CreateRecordingAsync(
|
||||
@@ -168,112 +173,81 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
var userId = actor.UserId ?? throw new UnauthorizedAccessException(
|
||||
"Creating a canonical recording requires a user actor.");
|
||||
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await ValidateActorAsync(context, actor, cancellationToken);
|
||||
var existing = await FindCanonicalByExactSignalsAsync(
|
||||
context,
|
||||
actor.TenantId,
|
||||
normalizedIsrc,
|
||||
normalizedMusicBrainzId,
|
||||
cancellationToken);
|
||||
if (existing != null)
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
EnsureSignalsCompatible(existing, normalizedIsrc, normalizedMusicBrainzId);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
context,
|
||||
actor,
|
||||
existing,
|
||||
_clock.UtcNow,
|
||||
cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
correlationId,
|
||||
"canonical-recording.create",
|
||||
"already-exists",
|
||||
new
|
||||
{
|
||||
canonicalRecordingId = existing.Id,
|
||||
hasIsrc = normalizedIsrc != null,
|
||||
hasMusicBrainzRecordingId = normalizedMusicBrainzId != null
|
||||
});
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
return await CompleteCreationAsync(
|
||||
actor, correlationId, existing, created: false, cancellationToken);
|
||||
}
|
||||
|
||||
var now = _clock.UtcNow;
|
||||
var record = new CanonicalRecordingRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedByUserId = userId,
|
||||
Isrc = normalizedIsrc,
|
||||
MusicBrainzRecordingId = normalizedMusicBrainzId,
|
||||
IsProvisional = normalizedMusicBrainzId == null,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
context.CanonicalRecordings.Add(record);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
context,
|
||||
actor,
|
||||
record,
|
||||
now,
|
||||
cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
correlationId,
|
||||
"canonical-recording.create",
|
||||
"created",
|
||||
new
|
||||
{
|
||||
canonicalRecordingId = record.Id,
|
||||
hasIsrc = normalizedIsrc != null,
|
||||
hasMusicBrainzRecordingId = normalizedMusicBrainzId != null
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
return await CompleteCreationAsync(
|
||||
actor, correlationId, record, created: true, cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
context.ChangeTracker.Clear();
|
||||
existing = await FindCanonicalByExactSignalsAsync(
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await ValidateActorAsync(context, actor, cancellationToken);
|
||||
var record = await FindCanonicalByExactSignalsAsync(
|
||||
context,
|
||||
actor.TenantId,
|
||||
normalizedIsrc,
|
||||
normalizedMusicBrainzId,
|
||||
cancellationToken);
|
||||
if (existing == null)
|
||||
var created = record == null;
|
||||
var enriched = false;
|
||||
var now = _clock.UtcNow;
|
||||
if (record == null)
|
||||
{
|
||||
throw;
|
||||
record = new CanonicalRecordingRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedByUserId = userId,
|
||||
Isrc = normalizedIsrc,
|
||||
MusicBrainzRecordingId = normalizedMusicBrainzId,
|
||||
IsProvisional = normalizedMusicBrainzId == null,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
context.CanonicalRecordings.Add(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
EnsureSignalsCompatible(record, normalizedIsrc, normalizedMusicBrainzId);
|
||||
enriched = record.Isrc == null && normalizedIsrc != null ||
|
||||
record.MusicBrainzRecordingId == null && normalizedMusicBrainzId != null;
|
||||
if (enriched)
|
||||
{
|
||||
record.Isrc ??= normalizedIsrc;
|
||||
record.MusicBrainzRecordingId ??= normalizedMusicBrainzId;
|
||||
record.IsProvisional = record.MusicBrainzRecordingId == null;
|
||||
record.UpdatedAt = now;
|
||||
record.Revision++;
|
||||
}
|
||||
}
|
||||
|
||||
EnsureSignalsCompatible(existing, normalizedIsrc, normalizedMusicBrainzId);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
context,
|
||||
actor,
|
||||
existing,
|
||||
_clock.UtcNow,
|
||||
cancellationToken);
|
||||
context, actor, record, now, cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
correlationId,
|
||||
context, actor, correlationId,
|
||||
"canonical-recording.create",
|
||||
"concurrent-existing",
|
||||
new { canonicalRecordingId = existing.Id });
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
created ? "created" : enriched ? "enriched" : "already-exists",
|
||||
new
|
||||
{
|
||||
canonicalRecordingId = record.Id,
|
||||
hasIsrc = normalizedIsrc != null,
|
||||
hasMusicBrainzRecordingId = normalizedMusicBrainzId != null
|
||||
});
|
||||
try
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException error) when (attempt < 2 && IsConcurrentRecordingWrite(error))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return await CompleteCreationAsync(
|
||||
actor, correlationId, existing, created: false, cancellationToken);
|
||||
actor, correlationId, record, created, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsConcurrentRecordingWrite(DbUpdateException error) =>
|
||||
error is DbUpdateConcurrencyException || error.InnerException is Npgsql.PostgresException
|
||||
{
|
||||
SqlState: Npgsql.PostgresErrorCodes.UniqueViolation
|
||||
} postgres && (postgres.ConstraintName is "IX_canonical_recordings_TenantId_Isrc" or
|
||||
"IX_canonical_recordings_TenantId_MusicBrainzRecordingId" ||
|
||||
postgres.ConstraintName?.StartsWith("IX_canonical_catalog_aliases_", StringComparison.Ordinal) == true);
|
||||
|
||||
private async Task<CanonicalRecordingCreationResult> CompleteCreationAsync(
|
||||
ProviderActorContext actor,
|
||||
string correlationId,
|
||||
@@ -281,7 +255,8 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
bool created,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_catalogRefreshQueue != null && recording.MusicBrainzRecordingId is { } mbid)
|
||||
if (_catalogRefreshQueue != null && _catalogSettings?.Value.Enabled != false &&
|
||||
recording.MusicBrainzRecordingId is { } mbid)
|
||||
{
|
||||
await _catalogRefreshQueue.EnqueueRecordingAsync(
|
||||
actor, mbid, correlationId, cancellationToken);
|
||||
@@ -759,7 +734,7 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidates = await context.CanonicalRecordings.AsNoTracking()
|
||||
var candidates = await context.CanonicalRecordings
|
||||
.Where(item => item.TenantId == tenantId &&
|
||||
((isrc != null && item.Isrc == isrc) ||
|
||||
(musicBrainzRecordingId != null &&
|
||||
|
||||
@@ -341,6 +341,7 @@ Checkpoint as of 2026-09-27; the catalog and alias projection changes are deploy
|
||||
- **Identity projection:** Creating a recording now projects exact ISRC and MusicBrainz aliases through the shared evidence owner. Both the identity service and matching commands project accepted provider identities transactionally with account- or catalog-scoped namespaces, so the same provider ID cannot leak or collide across account boundaries. Indexed Jellyfin and Subsonic/OpenSubsonic items use a protocol-and-backend-instance namespace; two users seeing the same native item converge only when their canonical assignments agree. Conflicting historical native assignments remain unaliased rather than being silently merged. Concurrent match writers treat an alias insert race as a retryable identity write. Forward migrations backfill provider, signal, and consistent native aliases and mark recordings without an MBID provisional. The isolated PostgreSQL lanes pass all 22 unique selected identity, migration, native-index, manual-selection, automatic-fallback, concurrency, and model-snapshot tests.
|
||||
- **Snapshot scope:** Snapshot capture validates a supplied provider identity against the tenant, source provider, track hash, catalog, and resolved account. Repeated captures must preserve the identity link and backend principal. PostgreSQL regressions cover foreign identities, both permitted identity scopes, and immutable retries.
|
||||
- **Source reconciliation:** Automatic rematching now moves a provisional source identity and its catalog alias together when it reuses an existing provider recording. A source anchored by an ISRC, MusicBrainz ID, confirmed recording, or manual identity cannot be reassigned by this path. The operation preserves old recordings and historical decisions, records an audit event, and retries concurrent identity writes. A forward migration repairs unanchored stale aliases, removes redundant aliases with identical targets, and normalizes legacy hashes; conflicting targets remain untouched. Isolated PostgreSQL regressions cover converging sources, repeated rematches, protected evidence, and migration replay. Generic evidence ingestion still rejects arbitrary alias reassignment. This is not a general-purpose recording merge.
|
||||
- **Native indexing:** The shared Jellyfin/Subsonic index now connects valid native recording MBIDs to the identity service. A compatible new ISRC or MBID enriches the existing recording without changing its ID or pinned provider routes. Rescans preserve canonical assignments and decision versions, and recover missing assignments from existing native aliases. Conflicting or malformed signals leave the native item indexed without merging recordings. Catalog discovery remains a durable job and is not queued when MusicBrainz is disabled. Reindexing an already linked MBID can request refresh after the setting is enabled. Native IDs, display metadata, and media files remain unchanged. Forty focused tests cover identity races, conflicts, ingestion, both scanners, and reindex preservation; deployment qualification is pending for this increment.
|
||||
- **Remaining:** Project remaining legacy source-snapshot and protocol identities into the catalog; add the relationship and image request shapes needed by Stage 3; and reconcile provisional records that begin without an MBID.
|
||||
|
||||
| Stage 2 checkpoint measure | Stage start | Identity-projection checkpoint | Interpretation |
|
||||
|
||||
Reference in new issue
Block a user