mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-07 14:03:14 -04:00
Compare commits
3
Commits
5584ff4257
...
1a72ecc3cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a72ecc3cb
|
||
|
|
e9641ac6f6
|
||
|
|
9970352367
|
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,9 +341,10 @@ 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. This increment is deployed; the live native-alias audit found no mismatches. Handling native rows no longer returned by a backend scan remains separate work.
|
||||
- **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 | Current | Interpretation |
|
||||
| Stage 2 checkpoint measure | Stage start | Identity-projection checkpoint | Interpretation |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Production source in the established C#/WebUI/Python scope | 135,882 | 137,000 | +1,118 lines implement the catalog graph, evidence owner, bounded client, ingestion/refresh jobs, and exact identity projection; migrations and tests are excluded, and no consolidation saving is claimed yet |
|
||||
| Accepted provider-identity alias writers | none | 1 shared evidence path | New links, repeat links, conflict checks, and the forward migration use the same namespace and hashing rules |
|
||||
@@ -436,6 +437,35 @@ The three-sample native stream run measured 34.7 ms mean Allstarr first-byte
|
||||
latency against 20.4 ms direct. OIDC remains disabled pending operator setup;
|
||||
its real identity-provider flow is not live-qualified.
|
||||
|
||||
Follow-up on 2026-09-27: `5584ff42` passed 2,515 standard and 105
|
||||
release-critical tests against isolated PostgreSQL databases, plus GitHub CI.
|
||||
Its deployed migration repaired 12 stale source aliases and normalized 1,242
|
||||
legacy alias hashes; the post-migration audit found neither inconsistency.
|
||||
The live suite ran 203 checks with one failure and seven blocked checks.
|
||||
Native and external full-song decoding and exact cached ranges passed, as did
|
||||
creation, rename, add, reorder, removal, and deletion of a throwaway playlist.
|
||||
The SoundCloud extension's track lookup succeeded, but its upstream stream
|
||||
endpoint returned 404. Allstarr returned 502, including on a targeted retry.
|
||||
SoundCloud playback remains unqualified and needs an extension/upstream fix;
|
||||
the passing metadata checks do not establish playability. The blocked checks
|
||||
cover cold external byte ranges, an omitted SoundCloud album relationship,
|
||||
an injected playlist not visible to the test account, sharing without a second
|
||||
user, and mutations requiring exact state restoration. No clean live-release
|
||||
qualification is claimed for this build.
|
||||
|
||||
Native-index follow-up on 2026-09-27: `e9641ac6` passed 2,530 standard and
|
||||
105 release-critical tests, formatting, Compose validation, and GitHub CI.
|
||||
The deployed library scan linked native recording identities and preserved the
|
||||
server's disabled MusicBrainz setting: no remote catalog jobs were created.
|
||||
The live suite passed 187 of 188 checks, including full-song decoding, native
|
||||
object parity, and exact cached ranges. SoundCloud streaming still failed.
|
||||
Five checks were blocked: a cold Deezer byte range, SoundCloud's missing album
|
||||
relationship, an injected playlist absent from the test account, and the two
|
||||
stateful modes not enabled for this run. Previously tested playlist mutations
|
||||
were not rerun. The three-sample native stream mean was 30.7 ms through
|
||||
Allstarr versus 24.0 ms direct. This is a backend identity/indexing increment;
|
||||
stable canonical client IDs and unified artist/album browsing remain unfinished.
|
||||
|
||||
## Code-reduction rules
|
||||
|
||||
Code reduction is a release objective, but deleting safety and observability is not simplification.
|
||||
|
||||
Reference in new issue
Block a user