mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 13:55:39 -04:00
feat(catalog): project accepted identities as aliases
This commit is contained in:
7 files changed
+501
-11
No files matched your search
@@ -86,6 +86,17 @@ public sealed class TrackIdentityServiceTests : IAsyncLifetime
|
||||
Assert.Equal(3, links.Count);
|
||||
Assert.All(links, link => Assert.Matches("^[0-9a-f]{64}$", link.ExternalIdHash));
|
||||
Assert.Single(await context.CanonicalRecordings.ToListAsync());
|
||||
var aliases = await context.CanonicalCatalogAliases
|
||||
.Where(item => item.EntityKind == CanonicalCatalogEntityKind.Recording)
|
||||
.ToListAsync();
|
||||
Assert.Equal(4, aliases.Count);
|
||||
Assert.Equal(
|
||||
["3135556", "qobuz-track-9", "spotify-track-1"],
|
||||
aliases.Where(item => item.Namespace.StartsWith("provider:", StringComparison.Ordinal))
|
||||
.Select(item => item.ExternalId)
|
||||
.Order()
|
||||
.ToArray());
|
||||
Assert.All(aliases, alias => Assert.Equal(recording.Recording.Id, alias.CanonicalEntityId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -208,6 +219,18 @@ public sealed class TrackIdentityServiceTests : IAsyncLifetime
|
||||
Assert.Equal(catalogRecording.Recording.Id, catalogResolution!.CanonicalRecordingId);
|
||||
Assert.Equal(accountRecording.Recording.Id, accountResolution!.CanonicalRecordingId);
|
||||
|
||||
await using (var database = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
var aliases = await database.CanonicalCatalogAliases
|
||||
.Where(item => item.ExternalId == "overlapping-id")
|
||||
.OrderBy(item => item.Namespace)
|
||||
.ToListAsync();
|
||||
Assert.Equal(2, aliases.Count);
|
||||
Assert.Equal(2, aliases.Select(item => item.Namespace).Distinct().Count());
|
||||
Assert.Contains(aliases, item => item.CanonicalEntityId == catalogRecording.Recording.Id);
|
||||
Assert.Contains(aliases, item => item.CanonicalEntityId == accountRecording.Recording.Id);
|
||||
}
|
||||
|
||||
var forgedSnapshot = new ProviderAccountContext(
|
||||
account.Id,
|
||||
account.ProviderId,
|
||||
@@ -343,6 +366,30 @@ public sealed class TrackIdentityServiceTests : IAsyncLifetime
|
||||
Assert.Equal("USRC17607839", reused.Recording.Isrc);
|
||||
Assert.Equal(mbid.ToString("D"), reused.Recording.MusicBrainzRecordingId);
|
||||
|
||||
await using (var database = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
var aliases = await database.CanonicalCatalogAliases
|
||||
.Where(item => item.CanonicalEntityId == first.Recording.Id)
|
||||
.OrderBy(item => item.Namespace)
|
||||
.ToListAsync();
|
||||
Assert.Collection(
|
||||
aliases,
|
||||
item =>
|
||||
{
|
||||
Assert.Equal("isrc", item.Namespace);
|
||||
Assert.Equal("USRC17607839", item.ExternalId);
|
||||
},
|
||||
item =>
|
||||
{
|
||||
Assert.Equal("musicbrainz", item.Namespace);
|
||||
Assert.Equal(mbid.ToString("D"), item.ExternalId);
|
||||
});
|
||||
Assert.False(await database.CanonicalRecordings
|
||||
.Where(item => item.Id == first.Recording.Id)
|
||||
.Select(item => item.IsProvisional)
|
||||
.SingleAsync());
|
||||
}
|
||||
|
||||
var isrcOnly = await _service.CreateRecordingAsync(
|
||||
actor,
|
||||
"signals-isrc-only",
|
||||
@@ -353,6 +400,13 @@ public sealed class TrackIdentityServiceTests : IAsyncLifetime
|
||||
"signals-mbid-only",
|
||||
musicBrainzRecordingId: otherMbid.ToString());
|
||||
Assert.NotEqual(isrcOnly.Recording.Id, mbidOnly.Recording.Id);
|
||||
await using (var database = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
Assert.True(await database.CanonicalRecordings
|
||||
.Where(item => item.Id == isrcOnly.Recording.Id)
|
||||
.Select(item => item.IsProvisional)
|
||||
.SingleAsync());
|
||||
}
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _service.CreateRecordingAsync(
|
||||
actor,
|
||||
"signals-conflict",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public sealed class CanonicalAliasMigrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Migration_ProjectsExistingSignalsAndScopedProviderIdentities()
|
||||
{
|
||||
await using var database = await PostgresTestDatabase.CreateAsync(useTemplate: false);
|
||||
await using (var oldSchema = new AllstarrDbContext(database.Options))
|
||||
{
|
||||
var migrator = oldSchema.Database.GetService<IMigrator>();
|
||||
await migrator.MigrateAsync("20260915000028_ExpandCanonicalCatalog");
|
||||
}
|
||||
|
||||
var now = new DateTimeOffset(2026, 9, 22, 12, 0, 0, TimeSpan.Zero);
|
||||
var tenantId = Guid.CreateVersion7();
|
||||
var userId = Guid.CreateVersion7();
|
||||
var accountId = Guid.CreateVersion7();
|
||||
var provisionalRecordingId = Guid.CreateVersion7();
|
||||
var musicBrainzRecordingId = Guid.CreateVersion7();
|
||||
const string externalId = "same-provider-track";
|
||||
const string isrc = "USRC17607839";
|
||||
const string mbid = "11111111-2222-3333-4444-555555555555";
|
||||
var externalIdHash = CanonicalCatalogKeys.Hash(externalId);
|
||||
await using (var seed = new AllstarrDbContext(database.Options))
|
||||
{
|
||||
seed.AddRange(
|
||||
new TenantRecord
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = "canonical-alias-migration",
|
||||
Name = "Canonical alias migration",
|
||||
CreatedAt = now
|
||||
},
|
||||
new PlatformUserRecord
|
||||
{
|
||||
Id = userId,
|
||||
TenantId = tenantId,
|
||||
DisplayName = "Catalog owner",
|
||||
Status = PlatformUserStatus.Active,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
},
|
||||
new ProviderAccountRecord
|
||||
{
|
||||
Id = accountId,
|
||||
TenantId = tenantId,
|
||||
OwnerUserId = userId,
|
||||
ProviderId = "fixture",
|
||||
DisplayName = "Fixture account",
|
||||
Scope = ProviderAccountScope.User,
|
||||
Enabled = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
},
|
||||
new CanonicalRecordingRecord
|
||||
{
|
||||
Id = provisionalRecordingId,
|
||||
TenantId = tenantId,
|
||||
CreatedByUserId = userId,
|
||||
Title = "Provider-only recording",
|
||||
Isrc = isrc,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
},
|
||||
new CanonicalRecordingRecord
|
||||
{
|
||||
Id = musicBrainzRecordingId,
|
||||
TenantId = tenantId,
|
||||
CreatedByUserId = userId,
|
||||
Title = "MusicBrainz recording",
|
||||
MusicBrainzRecordingId = mbid,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
},
|
||||
Identity(
|
||||
provisionalRecordingId,
|
||||
providerAccountId: null,
|
||||
ProviderIdentityScope.Catalog),
|
||||
Identity(
|
||||
musicBrainzRecordingId,
|
||||
accountId,
|
||||
ProviderIdentityScope.Account));
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var migrate = new AllstarrDbContext(database.Options))
|
||||
{
|
||||
await migrate.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
await using var verification = new AllstarrDbContext(database.Options);
|
||||
var recordings = await verification.CanonicalRecordings
|
||||
.OrderBy(item => item.Id)
|
||||
.ToDictionaryAsync(item => item.Id);
|
||||
Assert.True(recordings[provisionalRecordingId].IsProvisional);
|
||||
Assert.False(recordings[musicBrainzRecordingId].IsProvisional);
|
||||
|
||||
var aliases = await verification.CanonicalCatalogAliases
|
||||
.OrderBy(item => item.Namespace)
|
||||
.ToListAsync();
|
||||
Assert.Equal(4, aliases.Count);
|
||||
Assert.Contains(aliases, alias =>
|
||||
alias.Namespace == "isrc" &&
|
||||
alias.ExternalId == isrc &&
|
||||
alias.CanonicalEntityId == provisionalRecordingId);
|
||||
Assert.Contains(aliases, alias =>
|
||||
alias.Namespace == "musicbrainz" &&
|
||||
alias.ExternalId == mbid &&
|
||||
alias.CanonicalEntityId == musicBrainzRecordingId);
|
||||
|
||||
var catalogNamespace = CanonicalCatalogKeys.ProviderTrackNamespace(
|
||||
"fixture", ProviderResourceKind.Track, "default", ProviderIdentityScope.Catalog, null);
|
||||
var accountNamespace = CanonicalCatalogKeys.ProviderTrackNamespace(
|
||||
"fixture", ProviderResourceKind.Track, "default", ProviderIdentityScope.Account, accountId);
|
||||
Assert.NotEqual(catalogNamespace, accountNamespace);
|
||||
Assert.Contains(aliases, alias =>
|
||||
alias.Namespace == catalogNamespace &&
|
||||
alias.ExternalId == externalId &&
|
||||
alias.CanonicalEntityId == provisionalRecordingId);
|
||||
Assert.Contains(aliases, alias =>
|
||||
alias.Namespace == accountNamespace &&
|
||||
alias.ExternalId == externalId &&
|
||||
alias.CanonicalEntityId == musicBrainzRecordingId);
|
||||
|
||||
ProviderTrackIdentityRecord Identity(
|
||||
Guid canonicalRecordingId,
|
||||
Guid? providerAccountId,
|
||||
ProviderIdentityScope scope) => new()
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = tenantId,
|
||||
CanonicalRecordingId = canonicalRecordingId,
|
||||
ProviderAccountId = providerAccountId,
|
||||
ProviderId = "fixture",
|
||||
ResourceKind = ProviderResourceKind.Track,
|
||||
CatalogNamespace = "default",
|
||||
Scope = scope,
|
||||
ExternalId = externalId,
|
||||
ExternalIdHash = externalIdHash,
|
||||
Verification = ProviderIdentityVerification.Verified,
|
||||
VerificationMethod = "migration-fixture",
|
||||
DecisionVersion = 1,
|
||||
VerifiedAt = now,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,6 @@ public sealed class MigrationModelSnapshotTests
|
||||
using var context = new AllstarrDbContext(options);
|
||||
|
||||
Assert.False(context.Database.HasPendingModelChanges());
|
||||
Assert.Equal("20260915000028_ExpandCanonicalCatalog", context.Database.GetMigrations().Last());
|
||||
Assert.Equal("20260922000029_ProjectCanonicalAliases", context.Database.GetMigrations().Last());
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,51 @@ public sealed record CanonicalCatalogEvidenceResult(
|
||||
int FactsCreated,
|
||||
int FactsSuperseded);
|
||||
|
||||
public static class CanonicalCatalogKeys
|
||||
{
|
||||
private const string DefaultCatalog = "default";
|
||||
|
||||
public static string Hash(string value) => Convert.ToHexString(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
|
||||
public static string ProviderTrackNamespace(
|
||||
string providerId,
|
||||
ProviderResourceKind resourceKind,
|
||||
string? catalog,
|
||||
ProviderIdentityScope scope,
|
||||
Guid? providerAccountId)
|
||||
{
|
||||
providerId = ProviderContractValidation.ProviderId(providerId, nameof(providerId));
|
||||
if (resourceKind != ProviderResourceKind.Track)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(resourceKind),
|
||||
"Canonical recording aliases require a track resource.");
|
||||
}
|
||||
|
||||
catalog = catalog == null
|
||||
? DefaultCatalog
|
||||
: ProviderContractValidation.Catalog(catalog, nameof(catalog));
|
||||
if (scope is not ProviderIdentityScope.Catalog and not ProviderIdentityScope.Account ||
|
||||
scope == ProviderIdentityScope.Catalog && providerAccountId.HasValue ||
|
||||
scope == ProviderIdentityScope.Account && !providerAccountId.HasValue)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Provider alias scope and account must describe one exact identity boundary.",
|
||||
nameof(scope));
|
||||
}
|
||||
|
||||
var descriptor = string.Join(
|
||||
'\n',
|
||||
providerId,
|
||||
resourceKind.ToString(),
|
||||
catalog,
|
||||
scope.ToString(),
|
||||
providerAccountId?.ToString("D") ?? string.Empty);
|
||||
return $"provider:{Hash(descriptor)}";
|
||||
}
|
||||
}
|
||||
|
||||
public interface ICanonicalCatalogEvidenceStore
|
||||
{
|
||||
Task<CanonicalCatalogEvidenceResult> RecordAsync(
|
||||
@@ -92,7 +137,7 @@ public sealed class CanonicalCatalogEvidenceStore(
|
||||
var seenAliases = 0;
|
||||
foreach (var alias in NormalizeAliases(aliases))
|
||||
{
|
||||
var hash = Hash(alias.ExternalId);
|
||||
var hash = CanonicalCatalogKeys.Hash(alias.ExternalId);
|
||||
var existing = db.CanonicalCatalogAliases.Local.SingleOrDefault(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Namespace == alias.Namespace &&
|
||||
@@ -362,9 +407,6 @@ public sealed class CanonicalCatalogEvidenceStore(
|
||||
: throw new ArgumentOutOfRangeException(name, $"Values are limited to {maxLength} characters.");
|
||||
}
|
||||
|
||||
private static string Hash(string value) => Convert.ToHexString(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
|
||||
private void EnsureStorageReady()
|
||||
{
|
||||
if (storageState.GetSnapshot().Readiness != DurableStorageReadiness.Ready)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using allstarr.Core.Capabilities;
|
||||
@@ -173,6 +171,12 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
if (existing != null)
|
||||
{
|
||||
EnsureSignalsCompatible(existing, normalizedIsrc, normalizedMusicBrainzId);
|
||||
await ProjectCanonicalSignalAliasesAsync(
|
||||
context,
|
||||
actor,
|
||||
existing,
|
||||
_clock.UtcNow,
|
||||
cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
@@ -198,10 +202,17 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
CreatedByUserId = userId,
|
||||
Isrc = normalizedIsrc,
|
||||
MusicBrainzRecordingId = normalizedMusicBrainzId,
|
||||
IsProvisional = normalizedMusicBrainzId == null,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
context.CanonicalRecordings.Add(record);
|
||||
await ProjectCanonicalSignalAliasesAsync(
|
||||
context,
|
||||
actor,
|
||||
record,
|
||||
now,
|
||||
cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
@@ -236,6 +247,12 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
}
|
||||
|
||||
EnsureSignalsCompatible(existing, normalizedIsrc, normalizedMusicBrainzId);
|
||||
await ProjectCanonicalSignalAliasesAsync(
|
||||
context,
|
||||
actor,
|
||||
existing,
|
||||
_clock.UtcNow,
|
||||
cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
@@ -333,6 +350,12 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
UpdatedAt = now
|
||||
};
|
||||
context.ProviderTrackIdentities.Add(link);
|
||||
await ProjectProviderAliasAsync(
|
||||
context,
|
||||
executionContext.Actor,
|
||||
link,
|
||||
now,
|
||||
cancellationToken);
|
||||
AddLinkAudit(context, executionContext, request, "created", link.Id, null);
|
||||
|
||||
try
|
||||
@@ -494,6 +517,12 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
bool concurrent = false)
|
||||
{
|
||||
EnsureExactExternalId(existing, request.ExternalId.Value);
|
||||
await ProjectProviderAliasAsync(
|
||||
context,
|
||||
executionContext.Actor,
|
||||
existing,
|
||||
_clock.UtcNow,
|
||||
cancellationToken);
|
||||
var sameCanonical = existing.CanonicalRecordingId == request.CanonicalRecordingId;
|
||||
var outcome = sameCanonical
|
||||
? concurrent ? "concurrent-existing" : "already-linked"
|
||||
@@ -793,8 +822,77 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
externalId.ProviderId,
|
||||
externalId.ResourceKind,
|
||||
externalId.Catalog ?? DefaultCatalog,
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(externalId.Value)))
|
||||
.ToLowerInvariant());
|
||||
CanonicalCatalogKeys.Hash(externalId.Value));
|
||||
|
||||
private static async Task ProjectCanonicalSignalAliasesAsync(
|
||||
AllstarrDbContext context,
|
||||
ProviderActorContext actor,
|
||||
CanonicalRecordingRecord recording,
|
||||
DateTimeOffset observedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CanonicalCatalogAliasInput[] aliases =
|
||||
[
|
||||
.. recording.Isrc == null
|
||||
? []
|
||||
: new[] { new CanonicalCatalogAliasInput("isrc", recording.Isrc) },
|
||||
.. recording.MusicBrainzRecordingId == null
|
||||
? []
|
||||
: new[] { new CanonicalCatalogAliasInput("musicbrainz", recording.MusicBrainzRecordingId) }
|
||||
];
|
||||
if (aliases.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var fingerprint = CanonicalCatalogKeys.Hash(string.Join(
|
||||
'\n', aliases.Select(alias => $"{alias.Namespace}:{alias.ExternalId}")));
|
||||
await CanonicalCatalogEvidenceStore.RecordInContextAsync(
|
||||
context,
|
||||
actor,
|
||||
new CanonicalCatalogEntityReference(CanonicalCatalogEntityKind.Recording, recording.Id),
|
||||
new CanonicalCatalogSourceStamp(
|
||||
"canonical-signal",
|
||||
null,
|
||||
fingerprint,
|
||||
1,
|
||||
observedAt,
|
||||
null),
|
||||
aliases,
|
||||
[],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task ProjectProviderAliasAsync(
|
||||
AllstarrDbContext context,
|
||||
ProviderActorContext actor,
|
||||
ProviderTrackIdentityRecord identity,
|
||||
DateTimeOffset observedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var aliasNamespace = CanonicalCatalogKeys.ProviderTrackNamespace(
|
||||
identity.ProviderId,
|
||||
identity.ResourceKind,
|
||||
identity.CatalogNamespace,
|
||||
identity.Scope,
|
||||
identity.ProviderAccountId);
|
||||
await CanonicalCatalogEvidenceStore.RecordInContextAsync(
|
||||
context,
|
||||
actor,
|
||||
new CanonicalCatalogEntityReference(
|
||||
CanonicalCatalogEntityKind.Recording,
|
||||
identity.CanonicalRecordingId),
|
||||
new CanonicalCatalogSourceStamp(
|
||||
"provider-identity",
|
||||
$"{identity.Verification}:{identity.DecisionVersion}",
|
||||
CanonicalCatalogKeys.Hash($"{aliasNamespace}\n{identity.ExternalId}"),
|
||||
1,
|
||||
observedAt,
|
||||
null),
|
||||
[new CanonicalCatalogAliasInput(aliasNamespace, identity.ExternalId)],
|
||||
[],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static void EnsureExactExternalId(
|
||||
ProviderTrackIdentityRecord record,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace allstarr.Core.Storage.Migrations;
|
||||
|
||||
[DbContext(typeof(AllstarrDbContext))]
|
||||
[Migration("20260922000029_ProjectCanonicalAliases")]
|
||||
public sealed class ProjectCanonicalAliases : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "canonical_recordings"
|
||||
SET "IsProvisional" = TRUE
|
||||
WHERE "MusicBrainzRecordingId" IS NULL;
|
||||
|
||||
INSERT INTO "canonical_catalog_aliases"
|
||||
("Id", "TenantId", "EntityKind", "CanonicalEntityId", "Namespace",
|
||||
"ExternalId", "ExternalIdHash", "CreatedAt", "LastSeenAt")
|
||||
SELECT
|
||||
substr(encode(sha256(convert_to(
|
||||
'canonical-alias-v1' || E'\n' || 'isrc' || E'\n' ||
|
||||
recording."TenantId"::text || E'\n' || recording."Id"::text,
|
||||
'UTF8')), 'hex'), 1, 32)::uuid,
|
||||
recording."TenantId",
|
||||
'Recording',
|
||||
recording."Id",
|
||||
'isrc',
|
||||
recording."Isrc",
|
||||
encode(sha256(convert_to(recording."Isrc", 'UTF8')), 'hex'),
|
||||
recording."CreatedAt",
|
||||
recording."UpdatedAt"
|
||||
FROM "canonical_recordings" recording
|
||||
WHERE recording."Isrc" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "canonical_catalog_aliases" alias
|
||||
WHERE alias."TenantId" = recording."TenantId"
|
||||
AND alias."Namespace" = 'isrc'
|
||||
AND alias."EntityKind" = 'Recording'
|
||||
AND alias."ExternalIdHash" = encode(
|
||||
sha256(convert_to(recording."Isrc", 'UTF8')), 'hex'));
|
||||
|
||||
INSERT INTO "canonical_catalog_aliases"
|
||||
("Id", "TenantId", "EntityKind", "CanonicalEntityId", "Namespace",
|
||||
"ExternalId", "ExternalIdHash", "CreatedAt", "LastSeenAt")
|
||||
SELECT
|
||||
substr(encode(sha256(convert_to(
|
||||
'canonical-alias-v1' || E'\n' || 'musicbrainz' || E'\n' ||
|
||||
recording."TenantId"::text || E'\n' || recording."Id"::text,
|
||||
'UTF8')), 'hex'), 1, 32)::uuid,
|
||||
recording."TenantId",
|
||||
'Recording',
|
||||
recording."Id",
|
||||
'musicbrainz',
|
||||
recording."MusicBrainzRecordingId",
|
||||
encode(sha256(convert_to(recording."MusicBrainzRecordingId", 'UTF8')), 'hex'),
|
||||
recording."CreatedAt",
|
||||
recording."UpdatedAt"
|
||||
FROM "canonical_recordings" recording
|
||||
WHERE recording."MusicBrainzRecordingId" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "canonical_catalog_aliases" alias
|
||||
WHERE alias."TenantId" = recording."TenantId"
|
||||
AND alias."Namespace" = 'musicbrainz'
|
||||
AND alias."EntityKind" = 'Recording'
|
||||
AND alias."ExternalIdHash" = encode(
|
||||
sha256(convert_to(recording."MusicBrainzRecordingId", 'UTF8')), 'hex'));
|
||||
|
||||
INSERT INTO "canonical_catalog_aliases"
|
||||
("Id", "TenantId", "EntityKind", "CanonicalEntityId", "Namespace",
|
||||
"ExternalId", "ExternalIdHash", "CreatedAt", "LastSeenAt")
|
||||
SELECT
|
||||
substr(encode(sha256(convert_to(
|
||||
'canonical-alias-v1' || E'\n' || 'provider' || E'\n' || identity."Id"::text,
|
||||
'UTF8')), 'hex'), 1, 32)::uuid,
|
||||
identity."TenantId",
|
||||
'Recording',
|
||||
identity."CanonicalRecordingId",
|
||||
'provider:' || encode(sha256(convert_to(
|
||||
identity."ProviderId" || E'\n' || identity."ResourceKind" || E'\n' ||
|
||||
identity."CatalogNamespace" || E'\n' || identity."Scope" || E'\n' ||
|
||||
COALESCE(identity."ProviderAccountId"::text, ''),
|
||||
'UTF8')), 'hex'),
|
||||
identity."ExternalId",
|
||||
identity."ExternalIdHash",
|
||||
identity."CreatedAt",
|
||||
identity."UpdatedAt"
|
||||
FROM "provider_track_identities" identity
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "canonical_catalog_aliases" alias
|
||||
WHERE alias."TenantId" = identity."TenantId"
|
||||
AND alias."Namespace" = 'provider:' || encode(sha256(convert_to(
|
||||
identity."ProviderId" || E'\n' || identity."ResourceKind" || E'\n' ||
|
||||
identity."CatalogNamespace" || E'\n' || identity."Scope" || E'\n' ||
|
||||
COALESCE(identity."ProviderAccountId"::text, ''),
|
||||
'UTF8')), 'hex')
|
||||
AND alias."EntityKind" = 'Recording'
|
||||
AND alias."ExternalIdHash" = identity."ExternalIdHash");
|
||||
""");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DELETE FROM "canonical_catalog_aliases" alias
|
||||
USING "provider_track_identities" identity
|
||||
WHERE alias."Id" = substr(encode(sha256(convert_to(
|
||||
'canonical-alias-v1' || E'\n' || 'provider' || E'\n' || identity."Id"::text,
|
||||
'UTF8')), 'hex'), 1, 32)::uuid;
|
||||
|
||||
DELETE FROM "canonical_catalog_aliases" alias
|
||||
USING "canonical_recordings" recording
|
||||
WHERE alias."Id" = substr(encode(sha256(convert_to(
|
||||
'canonical-alias-v1' || E'\n' || 'isrc' || E'\n' ||
|
||||
recording."TenantId"::text || E'\n' || recording."Id"::text,
|
||||
'UTF8')), 'hex'), 1, 32)::uuid;
|
||||
|
||||
DELETE FROM "canonical_catalog_aliases" alias
|
||||
USING "canonical_recordings" recording
|
||||
WHERE alias."Id" = substr(encode(sha256(convert_to(
|
||||
'canonical-alias-v1' || E'\n' || 'musicbrainz' || E'\n' ||
|
||||
recording."TenantId"::text || E'\n' || recording."Id"::text,
|
||||
'UTF8')), 'hex'), 1, 32)::uuid;
|
||||
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -331,14 +331,20 @@ Exit: representative Jellyfin, Navidrome/OpenSubsonic, and provider entities con
|
||||
|
||||
#### Stage 2 status
|
||||
|
||||
Working tree as of 2026-09-14; not deployed:
|
||||
Checkpoint as of 2026-09-22; the alias projection changes are not yet deployed:
|
||||
|
||||
- **Storage:** The tenant-scoped recording stores provider-neutral title, version/disambiguation, duration, explicitness, and provisional status. The same graph owns artists, ordered credits, release groups, editions, release tracks, compatibility aliases, and append-only source facts. Composite tenant foreign keys block cross-tenant edges. A forward migration preserves existing IDs and routes.
|
||||
- **Evidence:** `CanonicalCatalogEvidenceStore` is the only writer for aliases and source facts. It validates actor scope, normalizes source IDs and JSON, rejects remapping and hash collisions, treats repeated payloads as idempotent, and supersedes changed facts without erasing provenance. PostgreSQL qualification round-tripped the complete artist → release group → edition → release track → recording graph.
|
||||
- **Catalog client:** The bounded `/ws/2` client supports source-scoped caches and artist, release-group, release, recording, ISRC, media, and release-track reads. Fixtures cover response hierarchy, cache isolation, size limits, rate limiting, cancellation, negative caching, and 401/403 handling. On 2026-09-15, eight operator-authorized BrainzMash requests passed with responses below 78 KB and latency from 656 ms to 11.129 seconds. A literal `Sunroof` search ranked acoustic/remix editions ahead of the base recording, confirming that Allstarr must rank normalized title, artist credit, duration, and version evidence itself.
|
||||
- **Atomic ingestion:** `MusicBrainzCatalogIngestService` validates the full payload before a serializable transaction, preserves separate editions, replaces provisional fields with canonical facts, and writes provenance through the shared evidence owner. Repeated input preserves IDs and creates no duplicate facts. A disposable PostgreSQL run passed 54 catalog, client, environment, migration-snapshot, and storage tests, including rollback of malformed media.
|
||||
- **Discovery and refresh:** `MusicBrainzCatalogRefreshQueue` creates seven-day idempotency generations scoped by tenant, user, source, and revision. Recording discovery accepts at most 50 distinct editions. Release refresh accepts one release hierarchy and at most 64 credited artists before atomic ingestion. Both jobs preserve upstream retry delays, separate permanent hierarchy failures from transient failures, and stay outside search and playback requests. The focused lane passes 62/62 tests; a separate PostgreSQL run passes all 21 selected identity, discovery, and ingestion tests.
|
||||
- **Remaining:** Project legacy identities into the catalog, add the relationship and image request shapes needed by Stage 3, and reconcile provisional records that begin without an MBID.
|
||||
- **Identity projection:** Creating a recording now projects exact ISRC and MusicBrainz aliases through the shared evidence owner. Accepted provider identities project account- or catalog-scoped aliases transactionally, so the same provider ID cannot leak or collide across account boundaries. A forward migration backfills those aliases and marks recordings without an MBID provisional. The isolated PostgreSQL lane passes all 15 selected identity, migration, and model-snapshot tests.
|
||||
- **Remaining:** Project legacy library, 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 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Production source in the established C#/WebUI/Python scope | 135,882 | 136,875 | +993 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 |
|
||||
|
||||
### Stage 3: Replace provider-shaped search with catalog search
|
||||
|
||||
|
||||
Reference in new issue
Block a user