mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-07 14:03:14 -04:00
Compare commits
10
Commits
736ebcf899
...
70e3aaaf76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70e3aaaf76
|
||
|
|
02f7532508
|
||
|
|
77ea5bd525
|
||
|
|
b2d95046c7
|
||
|
|
d58a3a1897
|
||
|
|
10a1cc55a0
|
||
|
|
0b4e562356
|
||
|
|
8dcfba9b04
|
||
|
|
1b16cb5fc6
|
||
|
|
3a7e646185
|
No files matched your search
@@ -6,6 +6,9 @@
|
||||
# Required production backend selection.
|
||||
BACKEND_TYPE=Jellyfin
|
||||
|
||||
# Shipped feature boundary. Use development only for unreleased surfaces.
|
||||
ALLSTARR_RELEASE_PROFILE=core
|
||||
|
||||
# PostgreSQL and encryption files.
|
||||
POSTGRES_DB=allstarr
|
||||
POSTGRES_USER=allstarr
|
||||
@@ -35,3 +38,11 @@ CORS_ALLOW_CREDENTIALS=false
|
||||
|
||||
# Optional Spotify lyrics profile secret.
|
||||
SPOTIFY_API_SESSION_COOKIE=
|
||||
|
||||
# Optional canonical metadata source. Keep the public MusicBrainz default until
|
||||
# your deployment's user agent has been approved by an alternative /ws/2 pool.
|
||||
MUSICBRAINZ_SOURCE_ID=musicbrainz
|
||||
MUSICBRAINZ_BASE_URL=https://musicbrainz.org/ws/2
|
||||
MUSICBRAINZ_RATE_LIMIT_MS=1000
|
||||
# Leave unset unless the upstream operator explicitly authorizes an alternate identity.
|
||||
MUSICBRAINZ_AUTHORIZED_USER_AGENT_OVERRIDE=
|
||||
@@ -11,6 +11,9 @@ concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: "10.0.301"
|
||||
NODE_VERSION: "22.23.1"
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
tags: ["v*"]
|
||||
branches: [main, beta]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: "10.0.301"
|
||||
NODE_VERSION: "22.23.1"
|
||||
|
||||
@@ -166,7 +166,7 @@ public sealed class AdminUpdateFeedTests : IAsyncLifetime
|
||||
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(5)
|
||||
};
|
||||
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
|
||||
await controller.Stream(cancellation.Token);
|
||||
|
||||
|
||||
@@ -99,6 +99,30 @@ public class ConfigControllerAuthorizationTests : IAsyncLifetime
|
||||
AssertForbidden(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EffectiveProviderPolicy_WithoutAdminSession_ReturnsForbidden()
|
||||
{
|
||||
var controller = CreateController(CreateHttpContextWithSession(isAdmin: false));
|
||||
|
||||
AssertForbidden(await controller.GetEffectiveProviderPolicy());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EffectiveProviderPolicy_ReturnsTenantPolicyWithoutSecretsOrAccountIds()
|
||||
{
|
||||
var controller = CreateController(CreateHttpContextWithSession(isAdmin: true));
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(await controller.GetEffectiveProviderPolicy());
|
||||
var json = JsonSerializer.Serialize(result.Value);
|
||||
|
||||
Assert.Contains("\"audioQuality\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"providerOrders\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"accountScopes\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"routeSelection\"", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(_providerAccountId.ToString(), json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("Secrets", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestartContainer_WithoutAdminSession_ReturnsForbidden()
|
||||
{
|
||||
@@ -515,12 +539,14 @@ public class ConfigControllerAuthorizationTests : IAsyncLifetime
|
||||
var clock = new SystemPlatformClock();
|
||||
var signal = new RuntimeSettingsChangeSignal();
|
||||
var durableSettings = new DurableRuntimeSettingsService(_factory, configuration, clock, signal);
|
||||
var effectiveProviderPolicies = new EffectiveProviderPolicyResolver(durableSettings);
|
||||
var migration = new LegacyEnvMigrationService(_factory, durableSettings, effectiveSecretStore, clock);
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton(providerStatusManager)
|
||||
.AddSingleton<IDbContextFactory<AllstarrDbContext>>(_factory)
|
||||
.AddSingleton(durableSettings)
|
||||
.AddSingleton<IDurableRuntimeSettings>(durableSettings)
|
||||
.AddSingleton<IEffectiveProviderPolicyResolver>(effectiveProviderPolicies)
|
||||
.AddSingleton(migration)
|
||||
.AddSingleton(effectiveSecretStore);
|
||||
httpContext.RequestServices = services.BuildServiceProvider();
|
||||
|
||||
@@ -157,6 +157,8 @@ public sealed class DownloadActivityControllerTests
|
||||
Assert.Equal(confirmed ? "qobuz" : "deezer", item.GetProperty("ProviderId").GetString());
|
||||
Assert.Equal("deezer", item.GetProperty("CatalogProviderId").GetString());
|
||||
Assert.Equal(confirmed, item.GetProperty("SourceConfirmed").GetBoolean());
|
||||
Assert.Equal(confirmed ? "provider-priority" : null,
|
||||
item.GetProperty("RouteReason").GetString());
|
||||
Assert.Equal(0.25, item.GetProperty("Progress").GetDouble());
|
||||
Assert.True(item.GetProperty("Scrobbled").GetBoolean());
|
||||
Assert.Equal(60, item.GetProperty("ScrobbleThresholdSeconds").GetDouble());
|
||||
@@ -165,6 +167,26 @@ public sealed class DownloadActivityControllerTests
|
||||
Assert.Equal("/api/admin/ui/users/backend-user-1/avatar", item.GetProperty("AvatarUrl").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NowPlaying_IdentifiesNativeLocalPlaybackWithoutAProviderLease()
|
||||
{
|
||||
var tenantId = Guid.CreateVersion7();
|
||||
var source = new StubPlaybackSource(new PlaybackActivityState(
|
||||
"device-1", "local-item", 0, DateTime.UtcNow, TenantId: tenantId));
|
||||
var controller = CreateController([], [source],
|
||||
[new StubMetadataResolver(new PlaybackTrackMetadata("Local", "Artist", null, null))]);
|
||||
controller.HttpContext.Items[AdminAuthSessionService.HttpContextSessionItemKey] = AdministratorSession(tenantId);
|
||||
|
||||
var result = await controller.GetNowPlaying(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
||||
var item = Assert.Single(document.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.Equal("jellyfin", item.GetProperty("ProviderId").GetString());
|
||||
Assert.Equal("native-local-library", item.GetProperty("RouteReason").GetString());
|
||||
Assert.True(item.GetProperty("SourceConfirmed").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Postgres")]
|
||||
public async Task NowPlaying_QueriesPortableTimestampColumnsWithConvertedParameters()
|
||||
|
||||
@@ -224,7 +224,11 @@ public sealed class PlaylistLinksControllerContractTests
|
||||
Assert.Contains("applicationCache.SetAsync(", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ImageConditionalRequestHelper.ComputeStrongETag(asset.Bytes)", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ImageConditionalRequestHelper.MatchesIfNoneMatch(Request.Headers, etag)", source, StringComparison.Ordinal);
|
||||
Assert.Contains("configuration[\"Providers:PlaylistOrder\"]", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ProviderOrderPolicyCatalog.Find(ProviderCapabilityKind.Playlist)", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("effectivePolicies.ResolveAsync(session.TenantId.Value", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("effectivePolicy?.ApplyProviderAvailability(", source, StringComparison.Ordinal);
|
||||
Assert.Contains("configuredProviderOrder.GetValueOrDefault", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("value.Artwork?.PublicUri", source, StringComparison.Ordinal);
|
||||
Assert.Equal(2, source.Split("new MediaAssetIdentity(", StringSplitOptions.None).Length - 1);
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class ComposeContractTests
|
||||
|
||||
Assert.Contains("postgres:18.4-alpine3.23@sha256:", compose, StringComparison.Ordinal);
|
||||
Assert.Contains("Storage__Provider: Postgres", compose, StringComparison.Ordinal);
|
||||
Assert.Contains("Release__Profile: \"${ALLSTARR_RELEASE_PROFILE:-core}\"", compose, StringComparison.Ordinal);
|
||||
Assert.Contains("Storage__PasswordFile: \"/run/secrets/postgres_password\"", compose, StringComparison.Ordinal);
|
||||
Assert.Contains("Secrets__KeyRingPath: \"/run/secrets/allstarr_keyring\"", compose, StringComparison.Ordinal);
|
||||
Assert.Contains("${ADMIN_PORT:-5275}:5275", compose, StringComparison.Ordinal);
|
||||
@@ -67,6 +68,7 @@ public sealed class ComposeContractTests
|
||||
"ADMIN_TRUSTED_SUBNETS",
|
||||
"ALLSTARR_IMAGE",
|
||||
"ALLSTARR_KEYRING_FILE",
|
||||
"ALLSTARR_RELEASE_PROFILE",
|
||||
"APPLE_UPLOAD_PATH",
|
||||
"BACKEND_TYPE",
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
@@ -74,6 +76,10 @@ public sealed class ComposeContractTests
|
||||
"DOWNLOAD_PATH",
|
||||
"EXTENSIONS_ALLOW_REMOTE_INSTALL",
|
||||
"KEPT_PATH",
|
||||
"MUSICBRAINZ_AUTHORIZED_USER_AGENT_OVERRIDE",
|
||||
"MUSICBRAINZ_BASE_URL",
|
||||
"MUSICBRAINZ_RATE_LIMIT_MS",
|
||||
"MUSICBRAINZ_SOURCE_ID",
|
||||
"POSTGRES_DB",
|
||||
"POSTGRES_PASSWORD_FILE",
|
||||
"POSTGRES_USER",
|
||||
@@ -148,8 +154,9 @@ public sealed class ComposeContractTests
|
||||
Assert.Contains("Duplicate .env key", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("--profile spotify-lyrics", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("--profile apple", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("git diff --quiet && git diff --cached --quiet", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("git pull --ff-only", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("git -c \"safe.directory=$ROOT\" diff --quiet", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("git -c \"safe.directory=$ROOT\" diff --cached --quiet", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("git -c \"safe.directory=$ROOT\" pull --ff-only", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("docker builder prune --force --min-free-space 8GB", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("tracked source files have local changes", controller, StringComparison.Ordinal);
|
||||
Assert.Contains("prepare-apple) prepare_apple \"$@\" ;;", controller, StringComparison.Ordinal);
|
||||
|
||||
@@ -150,7 +150,7 @@ public sealed class DurableRuntimeSettingsTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultTenantProjector_AppliesDurableOptionsAndRoutingWithoutTouchingSecrets()
|
||||
public async Task DefaultTenantProjector_AppliesOperationalOptionsWithoutProjectingTenantPolicyOrSecrets()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
@@ -172,19 +172,22 @@ public sealed class DurableRuntimeSettingsTests : IAsyncLifetime
|
||||
new("SpotifyApi:LyricsApiUrl", "http://spotify-lyrics:8080"),
|
||||
new("SpotifyImport:Playlists", "[[\"Discover Weekly\",\"source-id\",\"target-id\",\"last\",\"0 8 * * *\"]]")
|
||||
], "webui", _userId);
|
||||
var cache = new CacheSettings(); var deezer = new DeezerSettings { Arl = "bootstrap-secret" };
|
||||
var qobuz = new QobuzSettings();
|
||||
var apple = new AppleDownloadSettings { BaseUrl = "http://compose-apple-gateway:8000" };
|
||||
var cache = new CacheSettings();
|
||||
var deezer = new DeezerSettings { Arl = "bootstrap-secret", Quality = "MP3_128" };
|
||||
var qobuz = new QobuzSettings { Quality = "MP3_320" };
|
||||
var apple = new AppleDownloadSettings
|
||||
{
|
||||
BaseUrl = "http://compose-apple-gateway:8000",
|
||||
Quality = "aac-96"
|
||||
};
|
||||
var spotifyApi = new SpotifyApiSettings();
|
||||
var spotifyImport = new SpotifyImportSettings();
|
||||
var jellyfin = new JellyfinSettings(); var subsonic = new SubsonicSettings();
|
||||
var identity = new IdentityOptions { DefaultTenantId = _tenantId.ToString() };
|
||||
var matching = new TrackMatchPolicy();
|
||||
var projector = new DefaultTenantRuntimeSettingsProjector(service, signal, identity, configuration,
|
||||
Options.Create(cache), Options.Create(deezer), Options.Create(qobuz), Options.Create(apple),
|
||||
Options.Create(spotifyApi), Options.Create(spotifyImport),
|
||||
Options.Create(new MusicBrainzSettings()), Options.Create(new ScrobblingSettings()), Options.Create(jellyfin), Options.Create(subsonic),
|
||||
matching,
|
||||
NullLogger<DefaultTenantRuntimeSettingsProjector>.Instance);
|
||||
await projector.StartAsync(CancellationToken.None);
|
||||
for (var attempt = 0; attempt < 50 && cache.SearchResultsMinutes != 15; attempt++) await Task.Delay(10);
|
||||
@@ -194,16 +197,15 @@ public sealed class DurableRuntimeSettingsTests : IAsyncLifetime
|
||||
await service.ApplyBatchAsync(_tenantId,
|
||||
[new("Cache:SearchResultsMinutes", "22", 1), new(AudioQualityPolicy.SettingKey, "CdLossless", migrated.Revision)],
|
||||
"webui", _userId);
|
||||
for (var attempt = 0; attempt < 50 && (cache.SearchResultsMinutes != 22 || apple.Quality != "alac-16-44"); attempt++) await Task.Delay(10);
|
||||
for (var attempt = 0; attempt < 50 && cache.SearchResultsMinutes != 22; attempt++) await Task.Delay(10);
|
||||
await projector.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(22, cache.SearchResultsMinutes); Assert.Equal("FLAC", deezer.Quality);
|
||||
Assert.Equal(22, cache.SearchResultsMinutes); Assert.Equal("MP3_128", deezer.Quality);
|
||||
Assert.Equal("bootstrap-secret", deezer.Arl);
|
||||
Assert.Equal("deezer,qobuz", configuration["MULTI_PROVIDER_STREAMING_ORDER"]);
|
||||
Assert.Equal("qobuz", configuration["MULTI_PROVIDER_STREAMING_ORDER"]);
|
||||
Assert.Equal("http://compose-apple-gateway:8000", apple.BaseUrl);
|
||||
Assert.Equal("alac-16-44", apple.Quality);
|
||||
Assert.Equal("FLAC_16", qobuz.Quality);
|
||||
Assert.Equal(0.11, matching.LocalPriorityWindow);
|
||||
Assert.Equal("aac-96", apple.Quality);
|
||||
Assert.Equal("MP3_320", qobuz.Quality);
|
||||
Assert.Equal("http://spotify-lyrics:8080", spotifyApi.LyricsApiUrl);
|
||||
var importedPlaylist = Assert.Single(spotifyImport.Playlists);
|
||||
Assert.Equal("Discover Weekly", importedPlaylist.Name);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Settings;
|
||||
using Moq;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public sealed class EffectiveProviderPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Resolve_BuildsOneImmutableTenantSnapshot()
|
||||
{
|
||||
var tenantId = Guid.CreateVersion7();
|
||||
var settings = new Mock<IDurableRuntimeSettings>(MockBehavior.Strict);
|
||||
settings.Setup(item => item.GetManyAsync(
|
||||
tenantId,
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Guid _, IEnumerable<string> keys, CancellationToken _) =>
|
||||
keys.ToDictionary(key => key, Setting, StringComparer.OrdinalIgnoreCase));
|
||||
var resolver = new EffectiveProviderPolicyResolver(settings.Object);
|
||||
|
||||
var snapshot = await resolver.ResolveAsync(tenantId);
|
||||
|
||||
Assert.Equal(tenantId, snapshot.TenantId);
|
||||
Assert.Equal(["qobuz", "deezer"],
|
||||
snapshot.GetProviderOrder(ProviderCapabilityKind.Streaming));
|
||||
Assert.Contains("unavailable", snapshot.DisabledProviders);
|
||||
Assert.Equal(AudioQualityPolicy.DefaultStep, snapshot.AudioQuality);
|
||||
Assert.Equal(0.07, snapshot.LocalPreferenceWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Catalog_OwnsProviderOrderDefaults()
|
||||
{
|
||||
Assert.Equal("apple-download,deezer,qobuz",
|
||||
RuntimeSettingCatalog.Require("Providers:StreamingOrder").DefaultValue);
|
||||
Assert.Equal("spotify,apple-download,deezer,qobuz",
|
||||
RuntimeSettingCatalog.Require("Providers:PlaylistOrder").DefaultValue);
|
||||
}
|
||||
|
||||
private static EffectiveRuntimeSetting Setting(string key)
|
||||
{
|
||||
object value = key switch
|
||||
{
|
||||
"Providers:StreamingOrder" => new[] { "qobuz", "deezer" },
|
||||
"Providers:Disabled" => new[] { "unavailable" },
|
||||
AudioQualityPolicy.SettingKey => AudioQualityPolicy.DefaultStep,
|
||||
"Matching:LocalPreferencePercent" => 7,
|
||||
_ => Array.Empty<string>()
|
||||
};
|
||||
var type = value switch
|
||||
{
|
||||
string[] => RuntimeSettingValueType.StringList,
|
||||
int => RuntimeSettingValueType.Integer,
|
||||
_ => RuntimeSettingValueType.String
|
||||
};
|
||||
var normalized = value is string[] items ? string.Join(',', items) : value.ToString()!;
|
||||
return new EffectiveRuntimeSetting(
|
||||
key,
|
||||
type,
|
||||
value,
|
||||
normalized,
|
||||
RuntimeSettingOrigin.Durable,
|
||||
1,
|
||||
"test",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using allstarr.Core.Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public sealed class ReleaseCompositionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, "core", false)]
|
||||
[InlineData("core", "core", false)]
|
||||
[InlineData("development", "development", true)]
|
||||
public void Resolve_UsesExplicitClosedProfiles(
|
||||
string? configured,
|
||||
string expectedProfile,
|
||||
bool intelligenceEnabled)
|
||||
{
|
||||
var values = new Dictionary<string, string?> { ["Release:Profile"] = configured };
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
|
||||
var composition = ReleaseComposition.Resolve(configuration);
|
||||
|
||||
Assert.Equal(expectedProfile, composition.Profile);
|
||||
Assert.Equal(intelligenceEnabled, composition.IntelligenceEnabled);
|
||||
Assert.Equal(intelligenceEnabled,
|
||||
composition.Includes(ReleaseFeatureKind.Intelligence));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_RejectsUnknownProfile()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Release:Profile"] = "preview"
|
||||
})
|
||||
.Build();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => ReleaseComposition.Resolve(configuration));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CoreProfile_PausesRecommendationSchedulesWithoutDisablingCoreJobs()
|
||||
{
|
||||
Assert.False(ReleaseComposition.Core.IncludesScheduledJob("recommendation.generate"));
|
||||
Assert.True(ReleaseComposition.Core.IncludesScheduledJob("playlist.materialize"));
|
||||
Assert.True(ReleaseComposition.Development.IncludesScheduledJob("recommendation.generate"));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,32 @@ public sealed class RuntimeEnvConfigurationTests : IDisposable
|
||||
Assert.Equal("7", mapping.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapEnvVarToConfiguration_MapsReleaseProfile()
|
||||
{
|
||||
var mapping = Assert.Single(RuntimeEnvConfiguration
|
||||
.MapEnvVarToConfiguration("ALLSTARR_RELEASE_PROFILE", "development"));
|
||||
|
||||
Assert.Equal("Release:Profile", mapping.Key);
|
||||
Assert.Equal("development", mapping.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MUSICBRAINZ_SOURCE_ID", "MusicBrainz:SourceId")]
|
||||
[InlineData("MUSICBRAINZ_BASE_URL", "MusicBrainz:BaseUrl")]
|
||||
[InlineData("MUSICBRAINZ_RATE_LIMIT_MS", "MusicBrainz:RateLimitMs")]
|
||||
[InlineData("MUSICBRAINZ_AUTHORIZED_USER_AGENT_OVERRIDE", "MusicBrainz:AuthorizedUserAgentOverride")]
|
||||
public void MapEnvVarToConfiguration_MapsCanonicalCatalogSource(
|
||||
string environmentKey,
|
||||
string configurationKey)
|
||||
{
|
||||
var mapping = Assert.Single(RuntimeEnvConfiguration
|
||||
.MapEnvVarToConfiguration(environmentKey, "value"));
|
||||
|
||||
Assert.Equal(configurationKey, mapping.Key);
|
||||
Assert.Equal("value", mapping.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapEnvVarToConfiguration_MapsSharedBackendKeysToBothSections()
|
||||
{
|
||||
|
||||
@@ -246,7 +246,9 @@ public sealed class ExtensionCapabilityAdapterTests
|
||||
"type":["metadata_provider"],"permissions":{"storage":true}}
|
||||
""";
|
||||
const string script = """
|
||||
registerExtension({customSearch:function(){return [{id:'track-1',name:'Song',artists:'Artist',artist_id:'artist-1',album_id:'album-1',album_name:'Album',cover_url:'https://images.example.test/cover.jpg',item_type:'track'}];},getPlaylist:function(){return {tracks:[]};}});
|
||||
registerExtension({customSearch:function(query){return query === 'Song'
|
||||
? [{id:'track-1',name:'Song',artists:'Artist',artist_id:'artist-1',album_id:'album-1',album_name:'Album',cover_url:'https://images.example.test/cover.jpg',item_type:'track'}]
|
||||
: [{id:'wrong-track',name:'Wrong song',artists:'Wrong artist',item_type:'track'}];},getPlaylist:function(){return {tracks:[]};}});
|
||||
""";
|
||||
var manifest = SpotiFlacExtensionCompatibility.NormalizeManifest(sourceManifest, script);
|
||||
Assert.DoesNotContain(ExtensionSdkV1.ParseManifest(manifest).Capabilities,
|
||||
@@ -262,6 +264,30 @@ public sealed class ExtensionCapabilityAdapterTests
|
||||
Assert.Contains("\"id\":\"artist-1\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"albumId\":\"album-1\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("https://images.example.test/cover.jpg", json, StringComparison.Ordinal);
|
||||
|
||||
var detail = sandbox.InvokeJson("getTrack", "{\"id\":\"track-1\"}");
|
||||
|
||||
Assert.Contains("\"id\":\"track-1\"", detail, StringComparison.Ordinal);
|
||||
Assert.Contains("\"title\":\"Song\"", detail, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("wrong-track", detail, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Metadata_GetTrackRejectsAProviderResponseForAnotherIdentity()
|
||||
{
|
||||
var manifest = Manifest(ProviderCapabilityKind.Metadata, ["getTrack"]);
|
||||
var sandbox = Sandbox(manifest, """
|
||||
registerExtension({getTrack:function(){return {
|
||||
id:'other-track',title:'Wrong track',artists:[{name:'Wrong artist'}]
|
||||
};}});
|
||||
""");
|
||||
var adapter = new ExtensionMetadataCapabilityAdapter(sandbox, manifest);
|
||||
var requested = new ProviderExternalResourceId(manifest.Id, ProviderResourceKind.Track, "requested-track");
|
||||
|
||||
var outcome = await adapter.GetTrackAsync(Context(manifest.Id), new ProviderTrackLookupRequest(requested));
|
||||
|
||||
Assert.False(outcome.IsSuccess);
|
||||
Assert.Equal(ProviderErrorKind.NotFound, outcome.Error!.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
{
|
||||
"area": "public server info",
|
||||
"class": "transformed",
|
||||
"difference": "ProductName, LocalAddress, server Id, and operating system describe Allstarr while Version and ServerName reflect the backend connection."
|
||||
"difference": "Only LocalAddress changes so clients reconnect through Allstarr. Server identity, product, version, operating system, capabilities, and unknown fields remain Jellyfin-owned."
|
||||
},
|
||||
{
|
||||
"area": "music browse and search",
|
||||
|
||||
@@ -14,11 +14,18 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Core.Configuration;
|
||||
using allstarr.Core.Intelligence;
|
||||
using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Playback;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Moq;
|
||||
using System.Reflection;
|
||||
using System.Collections.Immutable;
|
||||
using allstarr.Core.Settings;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
@@ -56,7 +63,7 @@ public sealed class HostCompositionTests
|
||||
[Theory]
|
||||
[InlineData("Jellyfin", typeof(JellyfinController), typeof(SubsonicController))]
|
||||
[InlineData("Subsonic", typeof(SubsonicController), typeof(JellyfinController))]
|
||||
public void SelectedBackend_RegistersOneProtocolSurfaceAndActivatesEveryController(
|
||||
public void CoreRelease_RegistersOneProtocolSurfaceAndOmitsDeferredControllers(
|
||||
string backend,
|
||||
Type expectedProtocolController,
|
||||
Type excludedProtocolController)
|
||||
@@ -77,17 +84,28 @@ public sealed class HostCompositionTests
|
||||
Assert.Equal(
|
||||
backend.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase),
|
||||
controllerTypes.Contains(typeof(JellyfinAdminController)));
|
||||
Assert.DoesNotContain(typeof(IntelligenceController), controllerTypes);
|
||||
Assert.DoesNotContain(typeof(ListenBrainzIntakeController), controllerTypes);
|
||||
|
||||
var backendNeutralControllers = typeof(Program).Assembly.DefinedTypes
|
||||
.Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type))
|
||||
.Where(type => type.AsType() != typeof(JellyfinController) &&
|
||||
type.AsType() != typeof(SubsonicController) &&
|
||||
type.AsType() != typeof(JellyfinAdminController))
|
||||
type.AsType() != typeof(JellyfinAdminController) &&
|
||||
type.GetCustomAttribute<ReleaseFeatureAttribute>() == null)
|
||||
.Select(type => type.AsType())
|
||||
.ToArray();
|
||||
Assert.All(backendNeutralControllers, controllerType =>
|
||||
Assert.Contains(controllerType, controllerTypes));
|
||||
|
||||
var handlers = factory.Services.GetServices<IDurableJobHandler>().Select(item => item.GetType()).ToArray();
|
||||
Assert.DoesNotContain(typeof(RecommendationRunJobHandler), handlers);
|
||||
Assert.DoesNotContain(typeof(GeneratedSetMaterializationJobHandler), handlers);
|
||||
Assert.DoesNotContain(typeof(ListeningHistoryImportJobHandler), handlers);
|
||||
Assert.DoesNotContain(typeof(MusicBrainzListeningEnrichmentJobHandler), handlers);
|
||||
Assert.Contains(typeof(PlaybackSignalJobHandler), handlers);
|
||||
Assert.Empty(factory.Services.GetServices<IRecommendationProvider>());
|
||||
|
||||
var startupValidators = factory.Services.GetServices<IStartupValidator>().ToList();
|
||||
Assert.Single(startupValidators);
|
||||
Assert.Contains(backend, startupValidators[0].ServiceName, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -101,6 +119,27 @@ public sealed class HostCompositionTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DevelopmentRelease_ComposesDeferredIntelligenceSurface()
|
||||
{
|
||||
using var factory = new AllstarrFactory("Jellyfin", releaseProfile: "development");
|
||||
var controllerTypes = factory.Services
|
||||
.GetRequiredService<IActionDescriptorCollectionProvider>()
|
||||
.ActionDescriptors.Items
|
||||
.OfType<ControllerActionDescriptor>()
|
||||
.Select(item => item.ControllerTypeInfo.AsType())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(typeof(IntelligenceController), controllerTypes);
|
||||
Assert.Contains(typeof(ListenBrainzIntakeController), controllerTypes);
|
||||
Assert.NotEmpty(factory.Services.GetServices<IRecommendationProvider>());
|
||||
Assert.Contains(factory.Services.GetServices<IDurableJobHandler>(),
|
||||
handler => handler is RecommendationRunJobHandler);
|
||||
Assert.Contains(factory.Services.GetServices<IDurableJobHandler>(),
|
||||
handler => handler is MusicBrainzListeningEnrichmentJobHandler);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(JellyfinController), typeof(JellyfinAuthFilter))]
|
||||
[InlineData(typeof(SubsonicController), typeof(SubsonicAuthFilter))]
|
||||
@@ -151,7 +190,7 @@ public sealed class HostCompositionTests
|
||||
[InlineData("AdminManaged")]
|
||||
[InlineData("UserManaged")]
|
||||
[InlineData("Hybrid")]
|
||||
public void NonAdministratorSchema_ExposesOnlyReadyAccountSelfService(
|
||||
public async Task NonAdministratorSchema_ExposesOnlyReadyAccountSelfService(
|
||||
string managementMode)
|
||||
{
|
||||
using var factory = new AllstarrFactory("Jellyfin", managementMode);
|
||||
@@ -159,7 +198,7 @@ public sealed class HostCompositionTests
|
||||
var controller = ActivatorUtilities.CreateInstance<AdminUiController>(scope.ServiceProvider);
|
||||
controller.ControllerContext = Context(administrator: false);
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.GetSchema());
|
||||
var result = Assert.IsType<OkObjectResult>(await controller.GetSchema());
|
||||
var schema = Assert.IsType<AdminUiSchemaResponse>(result.Value);
|
||||
|
||||
Assert.Equal(managementMode, schema.ProviderAccountManagementMode);
|
||||
@@ -175,14 +214,14 @@ public sealed class HostCompositionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdministratorSchema_RetainsFullManagementSurface()
|
||||
public async Task AdministratorSchema_RetainsFullManagementSurface()
|
||||
{
|
||||
using var factory = new AllstarrFactory("Jellyfin");
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var controller = ActivatorUtilities.CreateInstance<AdminUiController>(scope.ServiceProvider);
|
||||
controller.ControllerContext = Context(administrator: true);
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.GetSchema());
|
||||
var result = Assert.IsType<OkObjectResult>(await controller.GetSchema());
|
||||
var schema = Assert.IsType<AdminUiSchemaResponse>(result.Value);
|
||||
|
||||
Assert.Contains(schema.Routes, route => route.Id == "settings");
|
||||
@@ -192,6 +231,34 @@ public sealed class HostCompositionTests
|
||||
Assert.Equal("/api/admin/extensions/packages", schema.ExtensionStore.InstalledEndpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdministratorSchema_UsesTheSessionTenantProviderPolicy()
|
||||
{
|
||||
var tenantId = Guid.CreateVersion7();
|
||||
var orders = ProviderOrderPolicyCatalog.Definitions.ToImmutableDictionary(
|
||||
item => item.Capability,
|
||||
item => (item.Capability == ProviderCapabilityKind.Streaming
|
||||
? new[] { "qobuz", "deezer" }
|
||||
: item.DefaultValue.Split(',')).ToImmutableArray());
|
||||
var policy = new EffectiveProviderPolicySnapshot(
|
||||
tenantId,
|
||||
orders,
|
||||
ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, "deezer"),
|
||||
"CdLossless",
|
||||
0.07);
|
||||
using var factory = new AllstarrFactory("Jellyfin", effectivePolicy: policy);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var controller = ActivatorUtilities.CreateInstance<AdminUiController>(scope.ServiceProvider);
|
||||
controller.ControllerContext = Context(administrator: true, tenantId: tenantId);
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(await controller.GetSchema());
|
||||
var schema = Assert.IsType<AdminUiSchemaResponse>(result.Value);
|
||||
var streaming = Assert.Single(schema.PriorityGroups, item => item.Id == "streaming");
|
||||
|
||||
Assert.Equal(["qobuz", "deezer"], streaming.Providers.Take(2));
|
||||
Assert.Equal("disabled", Assert.Single(schema.Providers, item => item.Id == "deezer").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_schema_does_not_call_missing_configuration_degraded()
|
||||
{
|
||||
@@ -212,7 +279,7 @@ public sealed class HostCompositionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdministratorSchema_IncludesActiveExtensionCapabilities()
|
||||
public async Task AdministratorSchema_IncludesActiveExtensionCapabilities()
|
||||
{
|
||||
using var factory = new AllstarrFactory("Jellyfin");
|
||||
using var scope = factory.Services.CreateScope();
|
||||
@@ -228,7 +295,7 @@ public sealed class HostCompositionTests
|
||||
var controller = ActivatorUtilities.CreateInstance<AdminUiController>(scope.ServiceProvider);
|
||||
controller.ControllerContext = Context(administrator: true);
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(controller.GetSchema());
|
||||
var result = Assert.IsType<OkObjectResult>(await controller.GetSchema());
|
||||
var schema = Assert.IsType<AdminUiSchemaResponse>(result.Value);
|
||||
var provider = Assert.Single(schema.Providers, item => item.Id == "fixture-extension");
|
||||
Assert.Equal("Fixture provider", provider.Description);
|
||||
@@ -288,7 +355,7 @@ public sealed class HostCompositionTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), clients.CreateClient(name).Timeout));
|
||||
}
|
||||
|
||||
private static ControllerContext Context(bool administrator)
|
||||
private static ControllerContext Context(bool administrator, Guid? tenantId = null)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Items[AdminAuthSessionService.HttpContextSessionItemKey] = new AdminAuthSession
|
||||
@@ -297,6 +364,7 @@ public sealed class HostCompositionTests
|
||||
UserId = "fixture",
|
||||
UserName = "fixture",
|
||||
IsAdministrator = administrator,
|
||||
TenantId = tenantId,
|
||||
JellyfinAccessToken = "fixture",
|
||||
ExpiresAtUtc = DateTime.UtcNow.AddHours(1),
|
||||
LastSeenUtc = DateTime.UtcNow
|
||||
@@ -416,6 +484,8 @@ public sealed class HostCompositionTests
|
||||
{
|
||||
private readonly string _backend;
|
||||
private readonly string _providerAccountManagementMode;
|
||||
private readonly string _releaseProfile;
|
||||
private readonly EffectiveProviderPolicySnapshot? _effectivePolicy;
|
||||
private readonly string _extensionDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"allstarr-tests",
|
||||
@@ -424,16 +494,21 @@ public sealed class HostCompositionTests
|
||||
|
||||
public AllstarrFactory(
|
||||
string backend,
|
||||
string providerAccountManagementMode = "Hybrid")
|
||||
string providerAccountManagementMode = "Hybrid",
|
||||
string releaseProfile = "core",
|
||||
EffectiveProviderPolicySnapshot? effectivePolicy = null)
|
||||
{
|
||||
_backend = backend;
|
||||
_providerAccountManagementMode = providerAccountManagementMode;
|
||||
_releaseProfile = releaseProfile;
|
||||
_effectivePolicy = effectivePolicy;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.UseSetting("Backend:Type", _backend);
|
||||
builder.UseSetting("Release:Profile", _releaseProfile);
|
||||
builder.UseSetting(
|
||||
"ProviderAccounts:ManagementMode",
|
||||
_providerAccountManagementMode);
|
||||
@@ -442,6 +517,7 @@ public sealed class HostCompositionTests
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Backend:Type"] = _backend,
|
||||
["Release:Profile"] = _releaseProfile,
|
||||
["ProviderAccounts:ManagementMode"] = _providerAccountManagementMode,
|
||||
["SpotifyApi:Enabled"] = "false",
|
||||
["SpotifyImport:Enabled"] = "false",
|
||||
@@ -453,7 +529,18 @@ public sealed class HostCompositionTests
|
||||
["MULTI_PROVIDER_DISABLED_PROVIDERS"] = "applemusic,deezer,qobuz,spotify"
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices(services => services.RemoveAll<IHostedService>());
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IHostedService>();
|
||||
if (_effectivePolicy == null) return;
|
||||
services.RemoveAll<IEffectiveProviderPolicyResolver>();
|
||||
var resolver = new Mock<IEffectiveProviderPolicyResolver>(MockBehavior.Strict);
|
||||
resolver.Setup(item => item.ResolveAsync(
|
||||
_effectivePolicy.TenantId,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_effectivePolicy);
|
||||
services.AddSingleton(resolver.Object);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Core.Intelligence;
|
||||
using allstarr.Core.Configuration;
|
||||
using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Playlists;
|
||||
@@ -118,6 +119,41 @@ public sealed class DurableScheduleEngineTests : IAsyncLifetime
|
||||
Assert.Empty(await db.Jobs.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CoreRelease_LeavesDeferredRecommendationScheduleUntouched()
|
||||
{
|
||||
await ConfigureRecommendationSchedule();
|
||||
_clock.UtcNow = _clock.UtcNow.AddMinutes(1);
|
||||
DateTimeOffset? nextRun;
|
||||
long revision;
|
||||
await using (var before = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
var schedule = await before.JobSchedules.SingleAsync();
|
||||
nextRun = schedule.NextRunAt;
|
||||
revision = schedule.Revision;
|
||||
}
|
||||
var options = new DurableJobOptions();
|
||||
var queue = new DurableJobQueue(
|
||||
_factory,
|
||||
options,
|
||||
new JobPayloadPolicy(options),
|
||||
_clock);
|
||||
var core = new DurableScheduleEngine(
|
||||
_factory,
|
||||
queue,
|
||||
_clock,
|
||||
ReleaseComposition.Core);
|
||||
|
||||
var result = await core.TickAsync();
|
||||
|
||||
Assert.Equal(new DurableScheduleTickResult(0, 0, 0, 0), result);
|
||||
await using var after = await _factory.CreateDbContextAsync();
|
||||
var unchanged = await after.JobSchedules.SingleAsync();
|
||||
Assert.Equal(nextRun, unchanged.NextRunAt);
|
||||
Assert.Equal(revision, unchanged.Revision);
|
||||
Assert.Empty(await after.Jobs.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MisfirePolicies_SkipOrRunOnlyOnce()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Services.MusicBrainz;
|
||||
using Moq;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public sealed class MusicBrainzCatalogRefreshServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RefreshRelease_LoadsBoundedHierarchyAndPassesSourceStampToAtomicIngest()
|
||||
{
|
||||
const string releaseId = "11111111-1111-4111-8111-111111111111";
|
||||
const string groupId = "22222222-2222-4222-8222-222222222222";
|
||||
const string artistId = "33333333-3333-4333-8333-333333333333";
|
||||
var now = new DateTimeOffset(2026, 9, 15, 16, 0, 0, TimeSpan.Zero);
|
||||
var artist = new MusicBrainzArtist { Id = artistId, Name = "Artist" };
|
||||
var credit = new MusicBrainzArtistCredit { Name = "Artist", Artist = artist };
|
||||
var release = new MusicBrainzRelease
|
||||
{
|
||||
Id = releaseId,
|
||||
Title = "Release",
|
||||
ReleaseGroup = new MusicBrainzReleaseGroup { Id = groupId, Title = "Group" },
|
||||
ArtistCredit = [credit],
|
||||
Media =
|
||||
[
|
||||
new MusicBrainzMedium
|
||||
{
|
||||
Position = 1,
|
||||
Tracks =
|
||||
[
|
||||
new MusicBrainzReleaseTrack
|
||||
{
|
||||
Id = "44444444-4444-4444-8444-444444444444",
|
||||
Position = 1,
|
||||
Title = "Track",
|
||||
Recording = new MusicBrainzRecording
|
||||
{
|
||||
Id = "55555555-5555-4555-8555-555555555555",
|
||||
Title = "Track",
|
||||
ArtistCredit = [credit]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
var group = new MusicBrainzReleaseGroup
|
||||
{
|
||||
Id = groupId,
|
||||
Title = "Group",
|
||||
ArtistCredit = [credit]
|
||||
};
|
||||
var client = new Mock<IMusicBrainzCatalogClient>(MockBehavior.Strict);
|
||||
client.SetupGet(item => item.ConfiguredSourceId).Returns("brainzmash");
|
||||
client.SetupGet(item => item.ConfiguredSourceRevision).Returns("brainzmash:ws2");
|
||||
client.Setup(item => item.LookupReleaseByMbidAsync(releaseId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(release);
|
||||
client.Setup(item => item.LookupReleaseGroupByMbidAsync(groupId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(group);
|
||||
client.Setup(item => item.LookupArtistByMbidAsync(artistId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(artist);
|
||||
var expected = new MusicBrainzCatalogIngestResult(
|
||||
Guid.CreateVersion7(), Guid.CreateVersion7(), 1, 1, 1, 5,
|
||||
new CanonicalCatalogEvidenceResult(5, 0, 5, 0));
|
||||
var ingest = new Mock<IMusicBrainzCatalogIngestService>(MockBehavior.Strict);
|
||||
ingest.Setup(item => item.IngestAsync(
|
||||
It.IsAny<ProviderActorContext>(),
|
||||
It.Is<MusicBrainzCatalogGraph>(graph =>
|
||||
graph.Release == release &&
|
||||
graph.ReleaseGroup == group &&
|
||||
graph.Artists.Count == 1 &&
|
||||
graph.Artists.Single() == artist),
|
||||
It.Is<MusicBrainzCatalogSource>(source =>
|
||||
source.SourceId == "brainzmash" &&
|
||||
source.SourceRevision == "brainzmash:ws2" &&
|
||||
source.ObservedAt == now &&
|
||||
source.RefreshAfter == now.AddDays(7)),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expected);
|
||||
var clock = new Mock<IPlatformClock>(MockBehavior.Strict);
|
||||
clock.SetupGet(item => item.UtcNow).Returns(now);
|
||||
var service = new MusicBrainzCatalogRefreshService(client.Object, ingest.Object, clock.Object);
|
||||
|
||||
var result = await service.RefreshReleaseAsync(Actor(), releaseId);
|
||||
|
||||
Assert.Same(expected, result);
|
||||
client.VerifyAll();
|
||||
ingest.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshRelease_RejectsIncompleteHierarchyBeforeIngest()
|
||||
{
|
||||
const string releaseId = "11111111-1111-4111-8111-111111111111";
|
||||
var client = new Mock<IMusicBrainzCatalogClient>(MockBehavior.Strict);
|
||||
client.Setup(item => item.LookupReleaseByMbidAsync(releaseId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MusicBrainzRelease { Id = releaseId, Title = "No group" });
|
||||
var ingest = new Mock<IMusicBrainzCatalogIngestService>(MockBehavior.Strict);
|
||||
var clock = new Mock<IPlatformClock>(MockBehavior.Strict);
|
||||
var service = new MusicBrainzCatalogRefreshService(client.Object, ingest.Object, clock.Object);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() =>
|
||||
service.RefreshReleaseAsync(Actor(), releaseId));
|
||||
|
||||
ingest.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DurableRefreshHandler_PreservesRetryableCatalogFailure()
|
||||
{
|
||||
const string releaseId = "11111111-1111-4111-8111-111111111111";
|
||||
var refresh = new Mock<IMusicBrainzCatalogRefreshService>(MockBehavior.Strict);
|
||||
refresh.Setup(item => item.RefreshReleaseAsync(
|
||||
It.IsAny<ProviderActorContext>(), releaseId, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new MusicBrainzLookupException(
|
||||
"catalog_source_temporarily_unavailable",
|
||||
"Catalog unavailable.",
|
||||
true,
|
||||
TimeSpan.FromSeconds(9)));
|
||||
var handler = new MusicBrainzCatalogRefreshJobHandler(refresh.Object);
|
||||
var claim = new DurableJobClaim(
|
||||
Guid.CreateVersion7(),
|
||||
Guid.CreateVersion7(),
|
||||
1,
|
||||
MusicBrainzCatalogRefreshJobHandler.Type,
|
||||
JsonSerializer.SerializeToElement(new MusicBrainzCatalogRefreshJobPayload(releaseId)),
|
||||
Guid.CreateVersion7(),
|
||||
Guid.CreateVersion7(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
JsonSerializer.SerializeToElement(new { }),
|
||||
"catalog-test",
|
||||
"worker",
|
||||
DateTimeOffset.UtcNow.AddMinutes(1));
|
||||
|
||||
var result = await handler.ExecuteAsync(
|
||||
new DurableJobExecutionContext(claim, Mock.Of<IServiceProvider>()),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(DurableJobCompletionKind.Retry, result.Kind);
|
||||
Assert.Equal("catalog_source_temporarily_unavailable", result.ErrorCode);
|
||||
Assert.Equal(TimeSpan.FromSeconds(9), result.RetryDelay);
|
||||
refresh.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoveryHandler_DeduplicatesEditionsAndQueuesBoundedRefreshes()
|
||||
{
|
||||
const string recordingId = "11111111-1111-4111-8111-111111111111";
|
||||
const string firstReleaseId = "22222222-2222-4222-8222-222222222222";
|
||||
const string secondReleaseId = "33333333-3333-4333-8333-333333333333";
|
||||
var client = new Mock<IMusicBrainzCatalogClient>(MockBehavior.Strict);
|
||||
client.Setup(item => item.LookupByMbidAsync(recordingId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MusicBrainzRecording
|
||||
{
|
||||
Id = recordingId,
|
||||
Title = "Track",
|
||||
Releases =
|
||||
[
|
||||
new MusicBrainzRelease { Id = firstReleaseId },
|
||||
new MusicBrainzRelease { Id = firstReleaseId.ToUpperInvariant() },
|
||||
new MusicBrainzRelease { Id = secondReleaseId }
|
||||
]
|
||||
});
|
||||
var queue = new Mock<IMusicBrainzCatalogRefreshQueue>(MockBehavior.Strict);
|
||||
queue.Setup(item => item.EnqueueReleaseAsync(
|
||||
It.Is<ProviderActorContext>(actor =>
|
||||
actor.Kind == ProviderActorKind.SystemJob &&
|
||||
actor.EffectiveUserId.HasValue),
|
||||
firstReleaseId,
|
||||
"catalog-test",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DurableJobEnqueueResult(Guid.CreateVersion7(), true));
|
||||
queue.Setup(item => item.EnqueueReleaseAsync(
|
||||
It.IsAny<ProviderActorContext>(),
|
||||
secondReleaseId,
|
||||
"catalog-test",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DurableJobEnqueueResult(Guid.CreateVersion7(), true));
|
||||
var progress = new List<DurableJobProgressUpdate>();
|
||||
var context = new DurableJobExecutionContext(
|
||||
Claim(
|
||||
MusicBrainzCatalogDiscoveryJobHandler.Type,
|
||||
new MusicBrainzCatalogDiscoveryJobPayload(recordingId)),
|
||||
Mock.Of<IServiceProvider>())
|
||||
{
|
||||
ReportProgressAsync = (update, _) =>
|
||||
{
|
||||
progress.Add(update);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
};
|
||||
|
||||
var result = await new MusicBrainzCatalogDiscoveryJobHandler(
|
||||
client.Object, queue.Object).ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(DurableJobCompletionKind.Succeeded, result.Kind);
|
||||
Assert.Equal(2, progress.Count);
|
||||
Assert.Equal(2, progress[^1].Completed);
|
||||
client.VerifyAll();
|
||||
queue.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoveryHandler_RejectsMalformedReleaseIdentityWithoutQueueing()
|
||||
{
|
||||
const string recordingId = "11111111-1111-4111-8111-111111111111";
|
||||
var client = new Mock<IMusicBrainzCatalogClient>(MockBehavior.Strict);
|
||||
client.Setup(item => item.LookupByMbidAsync(recordingId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MusicBrainzRecording
|
||||
{
|
||||
Id = recordingId,
|
||||
Releases = [new MusicBrainzRelease { Id = "not-an-mbid" }]
|
||||
});
|
||||
var queue = new Mock<IMusicBrainzCatalogRefreshQueue>(MockBehavior.Strict);
|
||||
var context = new DurableJobExecutionContext(
|
||||
Claim(
|
||||
MusicBrainzCatalogDiscoveryJobHandler.Type,
|
||||
new MusicBrainzCatalogDiscoveryJobPayload(recordingId)),
|
||||
Mock.Of<IServiceProvider>());
|
||||
|
||||
var result = await new MusicBrainzCatalogDiscoveryJobHandler(
|
||||
client.Object, queue.Object).ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(DurableJobCompletionKind.Failed, result.Kind);
|
||||
Assert.Equal("catalog_recording_hierarchy_invalid", result.ErrorCode);
|
||||
queue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
private static DurableJobClaim Claim<T>(string type, T payload) => new(
|
||||
Guid.CreateVersion7(),
|
||||
Guid.CreateVersion7(),
|
||||
1,
|
||||
type,
|
||||
JsonSerializer.SerializeToElement(payload),
|
||||
Guid.CreateVersion7(),
|
||||
Guid.CreateVersion7(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
JsonSerializer.SerializeToElement(new { }),
|
||||
"catalog-test",
|
||||
"worker",
|
||||
DateTimeOffset.UtcNow.AddMinutes(1));
|
||||
|
||||
private static ProviderActorContext Actor() => new(
|
||||
Guid.CreateVersion7(),
|
||||
ProviderActorKind.User,
|
||||
Guid.CreateVersion7(),
|
||||
new ProviderBackendPrincipal("jellyfin", "fixture", "listener"));
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
@@ -84,6 +86,46 @@ 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]
|
||||
public async Task RecordingWithMusicBrainzIdentity_QueuesIdempotentCatalogDiscovery()
|
||||
{
|
||||
const string recordingMbid = "11111111-1111-4111-8111-111111111111";
|
||||
var actor = Actor(_tenantA, _userA);
|
||||
var queue = new Mock<IMusicBrainzCatalogRefreshQueue>(MockBehavior.Strict);
|
||||
queue.Setup(item => item.EnqueueRecordingAsync(
|
||||
actor,
|
||||
recordingMbid,
|
||||
"catalog-discovery",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DurableJobEnqueueResult(Guid.CreateVersion7(), true));
|
||||
var service = new TrackIdentityService(_factory, _storageState, _clock, queue.Object);
|
||||
|
||||
var first = await service.CreateRecordingAsync(
|
||||
actor, "catalog-discovery", musicBrainzRecordingId: recordingMbid);
|
||||
var repeated = await service.CreateRecordingAsync(
|
||||
actor, "catalog-discovery", musicBrainzRecordingId: recordingMbid);
|
||||
|
||||
Assert.True(first.Created);
|
||||
Assert.False(repeated.Created);
|
||||
Assert.Equal(first.Recording.Id, repeated.Recording.Id);
|
||||
queue.Verify(item => item.EnqueueRecordingAsync(
|
||||
actor,
|
||||
recordingMbid,
|
||||
"catalog-discovery",
|
||||
It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -177,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,
|
||||
@@ -312,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",
|
||||
@@ -322,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",
|
||||
|
||||
@@ -100,6 +100,22 @@ public sealed class TrackMatchDecisionEngineTests(ITestOutputHelper output)
|
||||
Assert.True(scores[0].Confidence > scores[1].Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantLocalPreferenceCreatesAnIndependentMatcher()
|
||||
{
|
||||
var scope = Scope();
|
||||
var source = Source();
|
||||
var local = Candidate(scope);
|
||||
var shared = new TrackMatchDecisionEngine();
|
||||
var tenant = shared.WithLocalPriorityWindow(0.20);
|
||||
|
||||
var tenantWindow = tenant.ScoreCandidates(source, [local]).Single().Components!["priorityWindow"];
|
||||
var sharedWindow = shared.ScoreCandidates(source, [local]).Single().Components!["priorityWindow"];
|
||||
|
||||
Assert.Equal(0.20, tenantWindow);
|
||||
Assert.Equal(0.07, sharedWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LibraryIndexRevision_IsStableAndChangesWithMatchableMetadata()
|
||||
{
|
||||
@@ -765,11 +781,11 @@ public sealed class TrackMatchDecisionEngineTests(ITestOutputHelper output)
|
||||
Assert.Equal(local.LibraryTrackId, decision.Candidates[0].LibraryTrackId);
|
||||
Assert.Equal(localScore.Confidence, decision.Confidence);
|
||||
Assert.Equal(0.07, localScore.Components!["priorityWindow"]);
|
||||
Assert.Contains("local_priority_window_selected", decision.Reasons);
|
||||
Assert.Contains("accepted_route_priority_selected", decision.Reasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityWindowBoundaryIsInclusive()
|
||||
public void AcceptedLocalWinsOutsideItsPreferenceWindow()
|
||||
{
|
||||
var scope = Scope();
|
||||
var source = Source();
|
||||
@@ -795,35 +811,21 @@ public sealed class TrackMatchDecisionEngineTests(ITestOutputHelper output)
|
||||
}).Decide(scope, source, [provider, local]);
|
||||
|
||||
Assert.Equal(local.LibraryTrackId, inside.SelectedLibraryTrackId);
|
||||
Assert.Equal(provider.LibraryTrackId, outside.SelectedLibraryTrackId);
|
||||
Assert.Equal(local.LibraryTrackId, outside.SelectedLibraryTrackId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("apple-download", 244_000)]
|
||||
[InlineData("deezer", 242_100)]
|
||||
[InlineData("qobuz", 242_000)]
|
||||
[InlineData("fourth", 240_000)]
|
||||
public void ProviderPriorityWindowsFallFromFiveToThreeToOneToRawHighest(
|
||||
string expectedProvider,
|
||||
long expectedDuration)
|
||||
[InlineData("apple-download")]
|
||||
[InlineData("deezer")]
|
||||
[InlineData("qobuz")]
|
||||
[InlineData("fourth")]
|
||||
public void AcceptedProvidersUseConfiguredOrderInsteadOfScoreWindows(string expectedProvider)
|
||||
{
|
||||
var scope = Scope();
|
||||
var source = Source();
|
||||
var order = new[] { "apple-download", "deezer", "qobuz", "fourth" };
|
||||
var candidates = new[]
|
||||
{
|
||||
Candidate(scope) with
|
||||
{
|
||||
CanonicalRecordingId = null,
|
||||
Album = null,
|
||||
AlbumArtist = null,
|
||||
DurationMilliseconds = 247_000
|
||||
},
|
||||
ProviderCandidate(scope, "apple-download", expectedProvider == "apple-download" ? expectedDuration : 247_000),
|
||||
ProviderCandidate(scope, "deezer", expectedProvider == "deezer" ? expectedDuration : 245_000),
|
||||
ProviderCandidate(scope, "qobuz", expectedProvider == "qobuz" ? expectedDuration : 242_100),
|
||||
ProviderCandidate(scope, "fourth", 240_000)
|
||||
};
|
||||
var order = new[] { expectedProvider, "apple-download", "deezer", "qobuz", "fourth" }.Distinct().ToArray();
|
||||
var candidates = order.Select(provider =>
|
||||
ProviderCandidate(scope, provider, provider == expectedProvider ? 247_000 : 240_000)).ToArray();
|
||||
|
||||
var decision = new TrackMatchDecisionEngine().Decide(
|
||||
scope, source, candidates, providerPriority: order);
|
||||
@@ -832,12 +834,97 @@ public sealed class TrackMatchDecisionEngineTests(ITestOutputHelper output)
|
||||
Assert.Equal(
|
||||
candidates.Single(candidate => candidate.ProviderTrackIds?.ContainsKey(expectedProvider) == true).LibraryTrackId,
|
||||
decision.SelectedLibraryTrackId);
|
||||
Assert.Equal(expectedProvider == "fourth" ? 0 : expectedProvider switch
|
||||
Assert.True(decision.Candidates[0].Confidence < decision.Candidates[1].Confidence);
|
||||
Assert.Equal(1, decision.Candidates[0].Components!["routingPriority"]);
|
||||
Assert.Contains("accepted_route_priority_selected", decision.Reasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfiguredProviderUsesItsClosestAcceptedRecordingBeforeLaterProviders()
|
||||
{
|
||||
var scope = Scope();
|
||||
var preferredCloser = ProviderCandidate(scope, "apple-download", 241_000);
|
||||
var preferredFarther = ProviderCandidate(scope, "apple-download", 247_000) with
|
||||
{
|
||||
"apple-download" => 0.05,
|
||||
"deezer" => 0.03,
|
||||
_ => 0.01
|
||||
}, decision.Candidates[0].Components?.GetValueOrDefault("priorityWindow") ?? 0);
|
||||
LibraryTrackId = Guid.CreateVersion7(),
|
||||
BackendItemId = "apple-download-farther",
|
||||
ProviderTrackIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["apple-download"] = "apple-download-farther"
|
||||
}
|
||||
};
|
||||
var laterExact = ProviderCandidate(scope, "qobuz", 240_000);
|
||||
|
||||
var decision = new TrackMatchDecisionEngine().Decide(
|
||||
scope,
|
||||
Source(),
|
||||
[preferredFarther, laterExact, preferredCloser],
|
||||
providerPriority: ["apple-download", "qobuz"]);
|
||||
|
||||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||||
Assert.Equal(preferredCloser.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||||
Assert.True(decision.Candidates[0].Confidence <
|
||||
decision.Candidates.Single(item => item.LibraryTrackId == laterExact.LibraryTrackId).Confidence);
|
||||
Assert.Equal(1, decision.Candidates[0].Components!["routingPriority"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SunroofPrefersAcceptedLocalDespiteHigherExternalConfidence()
|
||||
{
|
||||
var scope = Scope();
|
||||
var source = Source() with
|
||||
{
|
||||
Title = "Sunroof",
|
||||
Artist = "Nicky Youre, hey daisy",
|
||||
Album = "Sunroof",
|
||||
AlbumArtist = null,
|
||||
DurationMilliseconds = 163_025
|
||||
};
|
||||
var local = Candidate(scope) with
|
||||
{
|
||||
Title = "Sunroof",
|
||||
Artist = "Nicky Youre, dazy",
|
||||
Album = "Sunroof",
|
||||
AlbumArtist = null,
|
||||
DurationMilliseconds = 163_025,
|
||||
CanonicalRecordingId = null
|
||||
};
|
||||
var external = ProviderCandidate(scope, "apple-download", 163_000) with
|
||||
{
|
||||
Title = "Sunroof",
|
||||
Artist = "Nicky Youre & hey daisy",
|
||||
Album = "Sunroof (Remixes) - EP"
|
||||
};
|
||||
|
||||
var decision = new TrackMatchDecisionEngine().Decide(scope, source, [external, local],
|
||||
providerPriority: ["apple-download", "deezer"]);
|
||||
|
||||
Assert.InRange(decision.Candidates.Single(candidate => candidate.IsLocal).Confidence, 0.88, 0.90);
|
||||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||||
Assert.Equal(local.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||||
Assert.InRange(decision.Confidence, 0.88, 0.90);
|
||||
Assert.True(decision.Candidates[1].Confidence - decision.Confidence > 0.07);
|
||||
Assert.Contains("accepted_route_priority_selected", decision.Reasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptedExternalBeatsPreferredLocalBelowAcceptanceThreshold()
|
||||
{
|
||||
var scope = Scope();
|
||||
var local = Candidate(scope) with
|
||||
{
|
||||
CanonicalRecordingId = null,
|
||||
Album = null,
|
||||
AlbumArtist = null,
|
||||
DurationMilliseconds = 400_000
|
||||
};
|
||||
var external = ProviderCandidate(scope, "deezer", 240_000);
|
||||
var decision = new TrackMatchDecisionEngine(new TrackMatchPolicy { LocalPriorityWindow = 0.20 })
|
||||
.Decide(scope, Source(), [local, external], providerPriority: ["deezer"]);
|
||||
|
||||
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
|
||||
Assert.Equal(external.LibraryTrackId, decision.SelectedLibraryTrackId);
|
||||
Assert.True(decision.Candidates.Single(candidate => candidate.IsLocal).Confidence < decision.AcceptThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -875,7 +962,7 @@ public sealed class TrackMatchDecisionEngineTests(ITestOutputHelper output)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProviderSearchIsSkippedOnlyWhenLocalCannotLoseItsSevenPointWindow()
|
||||
public void ProviderSearchIsSkippedForAnyAcceptedLocalMatch()
|
||||
{
|
||||
var scope = Scope();
|
||||
var source = Source();
|
||||
@@ -897,7 +984,9 @@ public sealed class TrackMatchDecisionEngineTests(ITestOutputHelper output)
|
||||
var vulnerableDecision = engine.Decide(scope, source, [vulnerable]);
|
||||
|
||||
Assert.True(engine.CanSkipProviderComparison(safeDecision));
|
||||
Assert.False(engine.CanSkipProviderComparison(vulnerableDecision));
|
||||
Assert.True(engine.CanSkipProviderComparison(vulnerableDecision));
|
||||
Assert.False(engine.CanSkipProviderComparison(engine.Decide(scope, source,
|
||||
[safe with { DurationMilliseconds = 400_000 }])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1148,9 +1237,7 @@ public sealed class TrackMatchDecisionEngineTests(ITestOutputHelper output)
|
||||
scores[external.LibraryTrackId].Confidence,
|
||||
scores[local.LibraryTrackId].Confidence);
|
||||
Assert.Equal(0.07, scores[local.LibraryTrackId].Components!["priorityWindow"]);
|
||||
Assert.DoesNotContain(
|
||||
"priorityWindow",
|
||||
scores[external.LibraryTrackId].Components?.Keys ?? []);
|
||||
Assert.Equal(0, scores[external.LibraryTrackId].Components!["priorityWindow"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Core.Matching;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public sealed class TrackSnapshotMetadataTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("""{"artist":"Nicky Youre","artists":["Nicky Youre","hey daisy"]}""")]
|
||||
[InlineData("""{"Artist":"Nicky Youre","Artists":["Nicky Youre","hey daisy"]}""")]
|
||||
[InlineData("""{"artists":[{"name":"Nicky Youre"},{"name":"hey daisy"},null,42]}""")]
|
||||
public void KeepsFullArtistCreditsAcrossSnapshotFormats(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
Assert.Equal("Nicky Youre, hey daisy", TrackSnapshotMetadata.Artist(document.RootElement));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"artists":[],"artist":"Nicky Youre"}""")]
|
||||
[InlineData("""{"Artists":[null,42,""],"primaryArtist":"Nicky Youre"}""")]
|
||||
public void UsesPrimaryArtistWhenNoUsableCreditsExist(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
Assert.Equal("Nicky Youre", TrackSnapshotMetadata.Artist(document.RootElement));
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,43 @@ public sealed class LibraryIndexServiceTests : IAsyncLifetime
|
||||
_service.ListAsync(UnlinkedContext(), "music"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanonicalTrack_ProjectsOneBackendAliasAcrossAuthorizedUsers()
|
||||
{
|
||||
var canonicalId = Guid.CreateVersion7();
|
||||
await using (var db = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
db.CanonicalRecordings.Add(new CanonicalRecordingRecord
|
||||
{
|
||||
Id = canonicalId,
|
||||
TenantId = _tenantId,
|
||||
CreatedByUserId = _userA,
|
||||
Title = "Song",
|
||||
IsProvisional = true,
|
||||
CreatedAt = _clock.UtcNow,
|
||||
UpdatedAt = _clock.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var input = Input() with
|
||||
{
|
||||
CanonicalRecordingId = canonicalId,
|
||||
AcceptedDecisionVersion = 1
|
||||
};
|
||||
await _service.UpsertAsync(Context(_userA, "principal-a", "music"), input);
|
||||
await _service.UpsertAsync(Context(_userB, "principal-b", "music"), input);
|
||||
|
||||
await using var verification = await _factory.CreateDbContextAsync();
|
||||
Assert.Equal(2, await verification.LibraryTracks.CountAsync());
|
||||
var alias = Assert.Single(await verification.CanonicalCatalogAliases.ToListAsync());
|
||||
Assert.Equal(
|
||||
CanonicalCatalogKeys.NativeTrackNamespace("jellyfin", "backend"),
|
||||
alias.Namespace);
|
||||
Assert.Equal("local-item", alias.ExternalId);
|
||||
Assert.Equal(canonicalId, alias.CanonicalEntityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IndexRejectsSignedUrlsAndSecretLikeProviderIds()
|
||||
{
|
||||
|
||||
@@ -748,6 +748,14 @@ public sealed class PlaylistOrchestrationIntegrationTests(ITestOutputHelper outp
|
||||
Assert.Equal(source.CanonicalRecordingId, selected.CanonicalRecordingId);
|
||||
Assert.Equal(ProviderIdentityVerification.Verified, source.Verification);
|
||||
Assert.Equal(ProviderIdentityVerification.Pinned, selected.Verification);
|
||||
var aliases = await verify.CanonicalCatalogAliases
|
||||
.Where(item => item.CanonicalEntityId == source.CanonicalRecordingId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(2, aliases.Count);
|
||||
Assert.All(aliases, item =>
|
||||
Assert.StartsWith("provider:", item.Namespace, StringComparison.Ordinal));
|
||||
Assert.Contains(aliases, item => item.ExternalId == source.ExternalId);
|
||||
Assert.Contains(aliases, item => item.ExternalId == selected.ExternalId);
|
||||
var review = await _trackMatches.GetReviewDataAsync(
|
||||
new TrackMatchActor(_tenant, _user, false),
|
||||
externalSnapshotId: externalSnapshotId);
|
||||
|
||||
@@ -3,6 +3,8 @@ using allstarr.Core.Identity;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Core.Settings;
|
||||
using System.Collections.Immutable;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Spotify;
|
||||
@@ -36,6 +38,37 @@ public sealed class PlaylistPlayableSearchServiceTests
|
||||
Assert.False(service.CanUseProvider("metadata-extension"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_eligibility_uses_the_tenant_snapshot()
|
||||
{
|
||||
var tenantId = Guid.CreateVersion7();
|
||||
var gateway = new Mock<IProtocolProviderGateway>();
|
||||
gateway.Setup(item => item.GetProviderOrder(ProviderCapabilityKind.Streaming))
|
||||
.Returns(["qobuz", "deezer"]);
|
||||
var policies = new Mock<IEffectiveProviderPolicyResolver>();
|
||||
policies.Setup(item => item.ResolveAsync(tenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EffectiveProviderPolicySnapshot(
|
||||
tenantId,
|
||||
new Dictionary<ProviderCapabilityKind, ImmutableArray<string>>
|
||||
{
|
||||
[ProviderCapabilityKind.Streaming] = ["qobuz", "deezer"]
|
||||
}.ToImmutableDictionary(),
|
||||
new[] { "qobuz" }.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase),
|
||||
AudioQualityPolicy.DefaultStep,
|
||||
0.07));
|
||||
var service = new PlaylistPlayableSearchService(
|
||||
gateway.Object,
|
||||
new TrackMatchDecisionEngine(),
|
||||
null!,
|
||||
new IdentityOptions(),
|
||||
Options.Create(new JellyfinSettings()),
|
||||
NullLogger<PlaylistPlayableSearchService>.Instance,
|
||||
policies.Object);
|
||||
|
||||
Assert.False(await service.CanUseProviderAsync(tenantId, "qobuz"));
|
||||
Assert.True(await service.CanUseProviderAsync(tenantId, "deezer"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Automatic_search_scores_all_playable_providers_and_selects_the_best()
|
||||
{
|
||||
@@ -145,7 +178,7 @@ public sealed class PlaylistPlayableSearchServiceTests
|
||||
var selected = Assert.Single(result.RoutableExternalCandidates);
|
||||
Assert.Equal("preferred", selected.ExternalId);
|
||||
Assert.Equal("preferred", result.SelectedExternal!.ExternalId);
|
||||
Assert.Contains("provider_priority_window_selected", result.Decision.Reasons);
|
||||
Assert.Contains("accepted_route_priority_selected", result.Decision.Reasons);
|
||||
Assert.True(result.Decision.Candidates[0].Confidence < result.Decision.Candidates[1].Confidence);
|
||||
}
|
||||
|
||||
@@ -271,6 +304,8 @@ public sealed class PlaylistPlayableSearchServiceTests
|
||||
Verification = ProviderIdentityVerification.Verified
|
||||
}
|
||||
],
|
||||
[],
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
@@ -284,6 +319,55 @@ public sealed class PlaylistPlayableSearchServiceTests
|
||||
It.IsAny<ProtocolExecutionContext>(), It.IsAny<string>(), It.IsAny<int>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CachedRouteUsesCurrentScoringAndLocalPriority()
|
||||
{
|
||||
var tenant = Guid.CreateVersion7();
|
||||
var user = Guid.CreateVersion7();
|
||||
var scope = new TrackMatchScope(tenant, user, "main", "music", Guid.CreateVersion7(), 2, 1);
|
||||
var source = new ExternalTrackMatchSnapshot("source", "spotify", "source-track", "Sunroof",
|
||||
"Nicky Youre, hey daisy", "Sunroof", null, 163_025, null, null, null);
|
||||
var local = new LocalTrackMatchCandidate(Guid.CreateVersion7(), tenant, user, "main", "music",
|
||||
"local-track", null, "Sunroof", "Nicky Youre, dazy", "Sunroof", null, 163_025, null, null, null);
|
||||
var gateway = new Mock<IProtocolProviderGateway>();
|
||||
gateway.Setup(item => item.GetProviderOrder(ProviderCapabilityKind.Streaming)).Returns(["apple-download"]);
|
||||
gateway.Setup(item => item.GetSongAsync(It.IsAny<ProtocolExecutionContext>(), "apple-download", "cached"))
|
||||
.ReturnsAsync(new Song
|
||||
{
|
||||
ExternalProvider = "apple-download",
|
||||
ExternalId = "cached",
|
||||
Title = "Sunroof",
|
||||
Artist = "Nicky Youre & hey daisy",
|
||||
Album = "Sunroof (Remixes) - EP",
|
||||
Duration = 163
|
||||
});
|
||||
var service = new PlaylistPlayableSearchService(gateway.Object, new TrackMatchDecisionEngine(), null!,
|
||||
new IdentityOptions(), Options.Create(new JellyfinSettings()), NullLogger<PlaylistPlayableSearchService>.Instance);
|
||||
var routes = new[] { new ProviderTrackIdentityRecord
|
||||
{
|
||||
TenantId = tenant, CanonicalRecordingId = Guid.CreateVersion7(), ProviderId = "apple-download",
|
||||
ExternalId = "cached", ResourceKind = ProviderResourceKind.Track, Verification = ProviderIdentityVerification.Verified
|
||||
} };
|
||||
|
||||
var result = await service.ReuseAsync(Context(tenant, user), source, scope, routes, [local], null, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(TrackMatchReviewState.Accepted, result.Decision.State);
|
||||
Assert.Equal(local.LibraryTrackId, result.Decision.SelectedLibraryTrackId);
|
||||
Assert.Null(result.SelectedExternal);
|
||||
Assert.Equal(2, result.Decision.Candidates.Count);
|
||||
Assert.All(result.Decision.Candidates, candidate => Assert.True(candidate.Confidence < 1));
|
||||
|
||||
var rejection = new ScopedTrackMatchOverride(tenant, user, "music", "spotify", "source-track", null,
|
||||
new HashSet<Guid> { local.LibraryTrackId });
|
||||
var rejectedLocal = await service.ReuseAsync(Context(tenant, user), source, scope, routes, [local], rejection, CancellationToken.None);
|
||||
Assert.Equal("cached", rejectedLocal!.SelectedExternal!.ExternalId);
|
||||
|
||||
var changedRecording = await service.ReuseAsync(Context(tenant, user), source with { Title = "Sunroof (Live)" },
|
||||
scope, routes, [], null, CancellationToken.None);
|
||||
Assert.Null(changedRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tentative_routes_are_freshly_searched_instead_of_reused()
|
||||
{
|
||||
@@ -321,6 +405,8 @@ public sealed class PlaylistPlayableSearchServiceTests
|
||||
VerificationMethod = "automatic-suggestion"
|
||||
}
|
||||
],
|
||||
[],
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
|
||||
@@ -24,6 +24,35 @@ public class AuthHeaderHelperTests
|
||||
Assert.False(request.Headers.Contains("X-Emby-Authorization"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildForwardedAuthorization_ShouldPreferModernAuthorization()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["Authorization"] = "MediaBrowser DeviceId=\"modern\", Token=\"new\"",
|
||||
["X-Emby-Authorization"] = "MediaBrowser DeviceId=\"legacy\", Token=\"old\""
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
"MediaBrowser DeviceId=\"modern\", Token=\"new\"",
|
||||
AuthHeaderHelper.BuildForwardedAuthorization(headers));
|
||||
Assert.Equal("modern", AuthHeaderHelper.ExtractDeviceId(headers));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildForwardedAuthorization_ShouldNormalizeLegacyAuthorization()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] =
|
||||
"MediaBrowser DeviceId=\"legacy-device\", Token=\"legacy-token\""
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
"MediaBrowser DeviceId=\"legacy-device\", Token=\"legacy-token\"",
|
||||
AuthHeaderHelper.BuildForwardedAuthorization(headers));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardAuthHeaders_ShouldPreserveMediaBrowserAuthorization()
|
||||
{
|
||||
@@ -70,6 +99,23 @@ public class AuthHeaderHelperTests
|
||||
Assert.False(request.Headers.Contains("X-Emby-Token"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildForwardedAuthorization_ShouldUpgradeTokenWithCallerDeviceId()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Token"] = "abc"
|
||||
};
|
||||
|
||||
var authorization = AuthHeaderHelper.BuildForwardedAuthorization(headers, "client-device");
|
||||
|
||||
Assert.NotNull(authorization);
|
||||
Assert.StartsWith("MediaBrowser ", authorization, StringComparison.Ordinal);
|
||||
Assert.Contains("DeviceId=\"client-device\"", authorization, StringComparison.Ordinal);
|
||||
Assert.Contains($"Version=\"{allstarr.AppVersion.Version}\"", authorization, StringComparison.Ordinal);
|
||||
Assert.Contains("Token=\"abc\"", authorization, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardAuthHeaders_ShouldForwardStandardAuthorization()
|
||||
{
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Collections.Immutable;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Identity;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Core.Protocols.Jellyfin;
|
||||
using allstarr.Core.Routing;
|
||||
using allstarr.Core.Settings;
|
||||
using allstarr.Core.Downloads;
|
||||
using allstarr.Services;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Local;
|
||||
@@ -59,8 +62,11 @@ public sealed partial class ProtocolProviderStreamingGatewayTests
|
||||
using var response = opened.Response;
|
||||
Assert.Equal("qobuz", opened.ServingProviderId);
|
||||
Assert.Equal("qobuz-track", opened.ServingExternalId);
|
||||
Assert.StartsWith("fallback-", opened.SelectionReason, StringComparison.Ordinal);
|
||||
Assert.Equal([1, 2, 3, 4], await response.Content.ReadAsByteArrayAsync());
|
||||
Assert.Equal("qobuz", Assert.Single(response.Headers.GetValues("X-Allstarr-Provider")));
|
||||
Assert.Equal(opened.SelectionReason,
|
||||
Assert.Single(response.Headers.GetValues("X-Allstarr-Route-Reason")));
|
||||
router.Verify(item => item.EvaluateFallback(It.IsAny<ProviderRoutePlan<IProviderStreamingCapability>>(), 0,
|
||||
It.Is<ProviderError>(error => error.Kind == kind)), Times.Once);
|
||||
if (failure is not ("transport" or "timeout" or "body")) Assert.True(content.Disposed);
|
||||
@@ -98,6 +104,135 @@ public sealed partial class ProtocolProviderStreamingGatewayTests
|
||||
It.IsAny<ProviderStreamLeaseRequest>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenStream_DoesNotLetALowerPriorityCacheHitJumpTheProviderOrder()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var cachedPath = Path.Combine(root, "cached.flac");
|
||||
await File.WriteAllBytesAsync(cachedPath, [9, 9, 9]);
|
||||
try
|
||||
{
|
||||
var context = Context();
|
||||
var local = new Mock<ILocalLibraryService>(MockBehavior.Strict);
|
||||
local.Setup(item => item.GetLocalPathForExternalSongAsync(
|
||||
It.Is<DownloadedSongMappingScope>(scope =>
|
||||
scope.TenantId == context.RequireActor().TenantId),
|
||||
"qobuz",
|
||||
"qobuz-track"))
|
||||
.ReturnsAsync((string?)null);
|
||||
local.Setup(item => item.GetLocalPathForExternalSongAsync(
|
||||
It.IsAny<DownloadedSongMappingScope>(),
|
||||
"deezer",
|
||||
"source-track"))
|
||||
.ReturnsAsync(cachedPath);
|
||||
var cache = new ManagedTrackCacheService(
|
||||
new ConfigurationBuilder().Build(),
|
||||
Options.Create(new SubsonicSettings()),
|
||||
local.Object,
|
||||
NullLogger<ManagedTrackCacheService>.Instance);
|
||||
var preferred = Streaming("qobuz", (_, _) => Task.FromResult(AudioResponse()));
|
||||
var cachedFallback = Streaming("deezer", (_, _) => Task.FromResult(AudioResponse()));
|
||||
var (gateway, _) = FailoverGateway(
|
||||
preferred,
|
||||
cachedFallback,
|
||||
managedTrackCache: cache);
|
||||
|
||||
var opened = await gateway.OpenStreamAsync(
|
||||
context, "deezer", "source-track", ProviderAudioQuality.Any, null);
|
||||
|
||||
using var response = Assert.IsType<ProtocolProviderStream>(opened).Response;
|
||||
Assert.Equal("qobuz", opened.ServingProviderId);
|
||||
Assert.False(opened.IsCached);
|
||||
Assert.Equal("provider-priority:account-free:remote", opened.SelectionReason);
|
||||
local.Verify(item => item.GetLocalPathForExternalSongAsync(
|
||||
It.IsAny<DownloadedSongMappingScope>(), "deezer", "source-track"), Times.Never);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenStream_UsesTenantPolicyInsteadOfProcessConfiguration()
|
||||
{
|
||||
var configuredFirst = Streaming("deezer", (_, _) => Task.FromResult(AudioResponse()));
|
||||
var tenantFirst = Streaming("qobuz", (_, _) => Task.FromResult(AudioResponse()));
|
||||
var (gateway, router) = FailoverGateway(
|
||||
configuredFirst,
|
||||
tenantFirst,
|
||||
tenantStreamingOrder: ["qobuz", "deezer"]);
|
||||
|
||||
var opened = await gateway.OpenStreamAsync(
|
||||
Context(),
|
||||
"deezer",
|
||||
"source-track",
|
||||
ProviderAudioQuality.Any,
|
||||
null);
|
||||
|
||||
using var response = Assert.IsType<ProtocolProviderStream>(opened).Response;
|
||||
Assert.Equal("qobuz", opened.ServingProviderId);
|
||||
router.Verify(item => item.PlanAsync<IProviderStreamingCapability>(
|
||||
It.Is<ProviderRouteRequest>(request =>
|
||||
request.ProviderPriority.SequenceEqual(new[] { "qobuz", "deezer" }))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenStream_UsesTenantQualityWhenTheClientDoesNotSetALowerCap()
|
||||
{
|
||||
var preferred = Streaming("qobuz", (_, _) => Task.FromResult(AudioResponse()));
|
||||
var fallback = Streaming("deezer", (_, _) => Task.FromResult(AudioResponse()));
|
||||
var (gateway, router) = FailoverGateway(
|
||||
preferred,
|
||||
fallback,
|
||||
tenantStreamingOrder: ["qobuz", "deezer"],
|
||||
tenantAudioQuality: "High");
|
||||
|
||||
using var response = (await gateway.OpenStreamAsync(
|
||||
Context(),
|
||||
"deezer",
|
||||
"source-track",
|
||||
ProviderAudioQuality.Any,
|
||||
null))!.Response;
|
||||
|
||||
router.Verify(item => item.PlanAsync<IProviderStreamingCapability>(
|
||||
It.Is<ProviderRouteRequest>(request =>
|
||||
request.Policy.Quality.Maximum == ProviderAudioQuality.Lossy)), Times.Once);
|
||||
preferred.Verify(item => item.GetStreamLeaseAsync(
|
||||
It.IsAny<ProviderExecutionContext>(),
|
||||
It.Is<ProviderStreamLeaseRequest>(request =>
|
||||
request.RequestedQuality == ProviderAudioQuality.Lossy)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenStream_ExcludesProvidersDisabledForTheTenant()
|
||||
{
|
||||
var disabled = Streaming("qobuz", (_, _) => Task.FromResult(AudioResponse()));
|
||||
var available = Streaming("deezer", (_, _) => Task.FromResult(AudioResponse()));
|
||||
var (gateway, router) = FailoverGateway(
|
||||
disabled,
|
||||
available,
|
||||
tenantStreamingOrder: ["qobuz", "deezer"],
|
||||
tenantDisabledProviders: ["qobuz"]);
|
||||
|
||||
var opened = await gateway.OpenStreamAsync(
|
||||
Context(),
|
||||
"deezer",
|
||||
"source-track",
|
||||
ProviderAudioQuality.Any,
|
||||
null);
|
||||
|
||||
using var response = Assert.IsType<ProtocolProviderStream>(opened).Response;
|
||||
Assert.Equal("deezer", opened.ServingProviderId);
|
||||
router.Verify(item => item.PlanAsync<IProviderStreamingCapability>(
|
||||
It.Is<ProviderRouteRequest>(request =>
|
||||
request.ProviderPriority.SequenceEqual(new[] { "deezer" }))), Times.Once);
|
||||
disabled.Verify(item => item.GetStreamLeaseAsync(
|
||||
It.IsAny<ProviderExecutionContext>(),
|
||||
It.IsAny<ProviderStreamLeaseRequest>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenStream_OnlyOneSameSourceRetryBeforeFailover()
|
||||
{
|
||||
@@ -266,13 +401,20 @@ public sealed partial class ProtocolProviderStreamingGatewayTests
|
||||
|
||||
private static (ProtocolProviderGateway Gateway, Mock<IProviderRouter> Router) FailoverGateway(
|
||||
Mock<IProviderStreamingCapability> first, Mock<IProviderStreamingCapability> second,
|
||||
PlaybackDeliveryActivityStore? activity = null)
|
||||
PlaybackDeliveryActivityStore? activity = null,
|
||||
IReadOnlyList<string>? tenantStreamingOrder = null,
|
||||
IReadOnlyList<string>? tenantDisabledProviders = null,
|
||||
string tenantAudioQuality = AudioQualityPolicy.DefaultStep,
|
||||
ManagedTrackCacheService? managedTrackCache = null)
|
||||
{
|
||||
var registry = Registry(first.Object, second.Object);
|
||||
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
||||
router.Setup(item => item.PlanAsync<IProviderStreamingCapability>(It.IsAny<ProviderRouteRequest>()))
|
||||
.ReturnsAsync((ProviderRouteRequest request) => Plan(request, registry,
|
||||
new[] { first.Object, second.Object }.Where(item => request.ProviderPriority.Contains(item.ProviderId)).ToArray()));
|
||||
new[] { first.Object, second.Object }
|
||||
.Where(item => request.ProviderPriority.Contains(item.ProviderId))
|
||||
.OrderBy(item => request.ProviderPriority.ToList().IndexOf(item.ProviderId))
|
||||
.ToArray()));
|
||||
router.Setup(item => item.EvaluateFallback(It.IsAny<ProviderRoutePlan<IProviderStreamingCapability>>(),
|
||||
It.IsAny<int>(), It.IsAny<ProviderError>()))
|
||||
.Returns((ProviderRoutePlan<IProviderStreamingCapability> plan, int index, ProviderError error) =>
|
||||
@@ -284,9 +426,27 @@ public sealed partial class ProtocolProviderStreamingGatewayTests
|
||||
{
|
||||
["Providers:StreamingOrder"] = $"{first.Object.ProviderId},{second.Object.ProviderId}"
|
||||
}).Build();
|
||||
IEffectiveProviderPolicyResolver? policies = null;
|
||||
if (tenantStreamingOrder != null)
|
||||
{
|
||||
var resolver = new Mock<IEffectiveProviderPolicyResolver>(MockBehavior.Strict);
|
||||
resolver.Setup(item => item.ResolveAsync(
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EffectiveProviderPolicySnapshot(
|
||||
Context().RequireActor().TenantId,
|
||||
new Dictionary<ProviderCapabilityKind, System.Collections.Immutable.ImmutableArray<string>>
|
||||
{
|
||||
[ProviderCapabilityKind.Streaming] = tenantStreamingOrder.ToImmutableArray()
|
||||
}.ToImmutableDictionary(),
|
||||
(tenantDisabledProviders ?? []).ToImmutableHashSet(StringComparer.OrdinalIgnoreCase),
|
||||
tenantAudioQuality,
|
||||
0.07));
|
||||
policies = resolver.Object;
|
||||
}
|
||||
return (new ProtocolProviderGateway(router.Object, registry, Mock.Of<IProviderRouteAccountResolver>(),
|
||||
Mock.Of<IMusicMetadataService>(MockBehavior.Strict), new HttpClientFactory(), configuration,
|
||||
playbackActivity: activity), router);
|
||||
managedTrackCache: managedTrackCache, playbackActivity: activity, effectivePolicies: policies), router);
|
||||
}
|
||||
|
||||
private static ProtocolExecutionContext ClientContext(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -452,12 +452,14 @@ public sealed partial class ProtocolProviderStreamingGatewayTests
|
||||
It.IsAny<ProviderExecutionContext>(),
|
||||
It.Is<ProviderStreamLeaseRequest>(request =>
|
||||
request.TrackId.ProviderId == "deezer" &&
|
||||
request.TrackId.Value == "source-track")), Times.Once);
|
||||
request.TrackId.Value == "source-track" &&
|
||||
request.RequestedQuality == ProviderAudioQuality.Lossless)), Times.Once);
|
||||
second.Verify(item => item.GetStreamLeaseAsync(
|
||||
It.IsAny<ProviderExecutionContext>(),
|
||||
It.Is<ProviderStreamLeaseRequest>(request =>
|
||||
request.TrackId.ProviderId == "qobuz" &&
|
||||
request.TrackId.Value == "qobuz-track")), Times.Once);
|
||||
request.TrackId.Value == "qobuz-track" &&
|
||||
request.RequestedQuality == ProviderAudioQuality.Lossless)), Times.Once);
|
||||
stream.Response.Dispose();
|
||||
first.VerifyAll();
|
||||
second.VerifyAll();
|
||||
|
||||
@@ -128,22 +128,86 @@ public sealed class ProtocolRouteFixtureTests
|
||||
[Fact]
|
||||
public async Task JellyfinPublicSystemInfo_BypassesCurrentUserVerification()
|
||||
{
|
||||
const string upstream = """
|
||||
{
|
||||
"LocalAddress":"http://jellyfin:8096",
|
||||
"Id":"fixture-server-id",
|
||||
"ServerName":"Fixture Server",
|
||||
"Version":"12.0.0",
|
||||
"ProductName":"Jellyfin Server",
|
||||
"OperatingSystem":"Linux",
|
||||
"StartupWizardCompleted":true,
|
||||
"UnknownFutureField":{"Keep":[1,2,3]}
|
||||
}
|
||||
""";
|
||||
var observedRequests = new List<string>();
|
||||
using var factory = new ProtocolFactory("Jellyfin", request =>
|
||||
{
|
||||
observedRequests.Add(request.RequestUri!.AbsolutePath);
|
||||
return Json(
|
||||
StatusCodes.Status200OK,
|
||||
"""{"ServerName":"Fixture Server","Version":"12.0.0"}""");
|
||||
return Json(StatusCodes.Status200OK, upstream);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/System/Info/Public");
|
||||
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
using var expected = JsonDocument.Parse(upstream);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("http://localhost", payload.RootElement.GetProperty("LocalAddress").GetString());
|
||||
foreach (var property in expected.RootElement.EnumerateObject().Where(property =>
|
||||
property.Name != "LocalAddress"))
|
||||
{
|
||||
Assert.True(payload.RootElement.TryGetProperty(property.Name, out var actual), property.Name);
|
||||
Assert.Equal(property.Value.GetRawText(), actual.GetRawText());
|
||||
}
|
||||
Assert.Equal(expected.RootElement.EnumerateObject().Count(),
|
||||
payload.RootElement.EnumerateObject().Count());
|
||||
Assert.Equal(["/System/Info/Public"], observedRequests);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/Items/00112233445566778899aabbccddeeff?api_key=fixture-key")]
|
||||
[InlineData("/Users/user-1/Items/00112233445566778899aabbccddeeff?api_key=fixture-key")]
|
||||
public async Task JellyfinNativeAudioDetail_PreservesEveryUpstreamField(string path)
|
||||
{
|
||||
const string audio = """
|
||||
{
|
||||
"Id":"00112233445566778899aabbccddeeff",
|
||||
"Name":"Fixture Track",
|
||||
"Type":"Audio",
|
||||
"MediaType":"Audio",
|
||||
"AlbumId":"ffeeddccbbaa99887766554433221100",
|
||||
"Artists":["Fixture Artist"],
|
||||
"ProviderIds":{"MusicBrainzTrack":"recording-1"},
|
||||
"UserData":{"IsFavorite":true,"PlayCount":7},
|
||||
"MediaSources":[{"Id":"source-1","Container":"flac"}],
|
||||
"UnknownFutureField":{"Keep":[1,2,3]}
|
||||
}
|
||||
""";
|
||||
var observedRequests = new List<string>();
|
||||
using var factory = new ProtocolFactory("Jellyfin", request =>
|
||||
{
|
||||
observedRequests.Add(request.RequestUri!.PathAndQuery);
|
||||
return request.RequestUri.AbsolutePath switch
|
||||
{
|
||||
"/Items" => ItemLookup(audio),
|
||||
"/Users/Me" => Json(StatusCodes.Status200OK, """{"Id":"user-1"}"""),
|
||||
_ => Json(StatusCodes.Status200OK, audio)
|
||||
};
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(audio), JsonNode.Parse(body)));
|
||||
Assert.Equal(3, observedRequests.Count);
|
||||
Assert.Equal("/Items?ids=00112233445566778899aabbccddeeff&limit=1", observedRequests[0]);
|
||||
Assert.Equal("/Users/Me?ApiKey=fixture-key", observedRequests[1]);
|
||||
Assert.Equal(ModernJellyfinPath(path), observedRequests[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JellyfinAuthenticatedSystemInfo_VerifiesAndRelaysWithoutReshaping()
|
||||
{
|
||||
|
||||
@@ -358,7 +358,15 @@ public sealed class ProtocolSupportMatrixTests
|
||||
["Key"],
|
||||
root.GetProperty("clientDtoRequirements").GetProperty("userDataRequired")
|
||||
.EnumerateArray().Select(value => value.GetString()!).ToArray());
|
||||
Assert.NotEmpty(root.GetProperty("intentionalDifferences").EnumerateArray());
|
||||
var differences = root.GetProperty("intentionalDifferences").EnumerateArray().ToArray();
|
||||
Assert.NotEmpty(differences);
|
||||
var publicInfo = Assert.Single(differences, difference =>
|
||||
difference.GetProperty("area").GetString() == "public server info");
|
||||
Assert.Equal("transformed", publicInfo.GetProperty("class").GetString());
|
||||
Assert.Equal(
|
||||
"Only LocalAddress changes so clients reconnect through Allstarr. " +
|
||||
"Server identity, product, version, operating system, capabilities, and unknown fields remain Jellyfin-owned.",
|
||||
publicInfo.GetProperty("difference").GetString());
|
||||
Assert.Contains(root.GetProperty("liveModes").EnumerateArray(),
|
||||
mode => mode.GetProperty("status").GetString() == "blocked-without-explicit-opt-in");
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ public sealed class AudioQualityPolicyTests
|
||||
Assert.Equal((apple, deezer, qobuz), actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DataSaver", ProviderAudioQuality.DataSaver)]
|
||||
[InlineData("High", ProviderAudioQuality.Lossy)]
|
||||
[InlineData("CdLossless", ProviderAudioQuality.Lossless)]
|
||||
[InlineData("HiResLossless", ProviderAudioQuality.HighResolution)]
|
||||
[InlineData("BestAvailable", ProviderAudioQuality.HighResolution)]
|
||||
public void RequestedQuality_MapsEverySharedStep(
|
||||
string step,
|
||||
ProviderAudioQuality expected) =>
|
||||
Assert.Equal(expected, AudioQualityPolicy.RequestedQuality(step));
|
||||
|
||||
[Theory]
|
||||
[InlineData("128", ProviderAudioQuality.DataSaver)]
|
||||
[InlineData("191", ProviderAudioQuality.DataSaver)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using allstarr.Services.Local;
|
||||
using allstarr.Core.Downloads;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
@@ -127,6 +128,38 @@ public class LocalLibraryServiceTests : IDisposable
|
||||
Assert.Equal(localPath, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScopedDownloadedSong_IsVisibleOnlyToTheExactPlaybackScope()
|
||||
{
|
||||
var tenant = Guid.CreateVersion7();
|
||||
var account = Guid.CreateVersion7();
|
||||
var scope = new DownloadedSongMappingScope(
|
||||
tenant, account, "music", ProviderAudioQuality.Lossless);
|
||||
var song = new Song
|
||||
{
|
||||
Id = "ext-deezer-song-private",
|
||||
Title = "Scoped song",
|
||||
Artist = "Artist",
|
||||
Album = "Album",
|
||||
ExternalProvider = "deezer",
|
||||
ExternalId = "private"
|
||||
};
|
||||
var localPath = Path.Combine(_testDownloadPath, "scoped.flac");
|
||||
await File.WriteAllTextAsync(localPath, "audio");
|
||||
|
||||
await _service.RegisterDownloadedSongAsync(scope, song, localPath);
|
||||
|
||||
Assert.Equal(localPath, await _service.GetLocalPathForExternalSongAsync(
|
||||
scope, "deezer", "private"));
|
||||
Assert.Null(await _service.GetLocalPathForExternalSongAsync(
|
||||
scope with { TenantId = Guid.CreateVersion7() }, "deezer", "private"));
|
||||
Assert.Null(await _service.GetLocalPathForExternalSongAsync(
|
||||
scope with { ProviderAccountId = Guid.CreateVersion7() }, "deezer", "private"));
|
||||
Assert.Null(await _service.GetLocalPathForExternalSongAsync(
|
||||
scope with { AudioQuality = ProviderAudioQuality.Lossy }, "deezer", "private"));
|
||||
Assert.Null(await _service.GetLocalPathForExternalSongAsync("deezer", "private"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocalPathForExternalSongAsync_WhenFileDeleted_ReturnsNull()
|
||||
{
|
||||
@@ -216,14 +249,24 @@ public class LocalLibraryServiceTests : IDisposable
|
||||
|
||||
public Task<DownloadedSongMappingEntity?> FindAsync(string providerId, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{providerId}:{externalId}";
|
||||
var key = $"{DownloadedSongMappingScope.LegacyKey}:{providerId}:{externalId}";
|
||||
_items.TryGetValue(key, out var mapping);
|
||||
return Task.FromResult(mapping);
|
||||
}
|
||||
|
||||
public Task<DownloadedSongMappingEntity?> FindAsync(
|
||||
DownloadedSongMappingScope scope,
|
||||
string providerId,
|
||||
string externalId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_items.TryGetValue($"{scope.Key}:{providerId}:{externalId}", out var mapping);
|
||||
return Task.FromResult(mapping);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(DownloadedSongMappingEntity mapping, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{mapping.ProviderId}:{mapping.ExternalId}";
|
||||
var key = $"{mapping.ScopeKey}:{mapping.ProviderId}:{mapping.ExternalId}";
|
||||
_items[key] = mapping;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Downloads;
|
||||
using allstarr.Core.Identity;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
@@ -27,11 +29,14 @@ public sealed class ManagedTrackCacheServiceTests
|
||||
var provider = fallback ? "qobuz" : "deezer";
|
||||
var externalId = fallback ? "other-track" : "track-1";
|
||||
var local = new Mock<ILocalLibraryService>(MockBehavior.Strict);
|
||||
local.Setup(item => item.GetLocalPathForExternalSongAsync(provider, externalId))
|
||||
local.Setup(item => item.GetLocalPathForExternalSongAsync(
|
||||
It.IsAny<DownloadedSongMappingScope>(), provider, externalId))
|
||||
.ReturnsAsync((string?)null);
|
||||
local.Setup(item => item.RegisterDownloadedSongAsync(It.IsAny<Song>(), It.IsAny<string>()))
|
||||
.Callback<Song, string>((song, path) =>
|
||||
local.Setup(item => item.RegisterDownloadedSongAsync(
|
||||
It.IsAny<DownloadedSongMappingScope>(), It.IsAny<Song>(), It.IsAny<string>()))
|
||||
.Callback<DownloadedSongMappingScope, Song, string>((scope, song, path) =>
|
||||
{
|
||||
Assert.Equal(ProviderAudioQuality.Lossless, scope.AudioQuality);
|
||||
Assert.Equal(provider, song.ExternalProvider);
|
||||
Assert.Equal(externalId, song.ExternalId);
|
||||
registeredPath = path;
|
||||
@@ -42,10 +47,15 @@ public sealed class ManagedTrackCacheServiceTests
|
||||
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 3, 4);
|
||||
|
||||
await service.WrapAsync(
|
||||
ProviderStream(response) with { ServingProviderId = provider, ServingExternalId = externalId },
|
||||
ProviderStream(response) with
|
||||
{
|
||||
ServingProviderId = provider,
|
||||
ServingExternalId = externalId,
|
||||
EffectiveQuality = ProviderAudioQuality.Lossless
|
||||
},
|
||||
Context(),
|
||||
"deezer",
|
||||
"track-1",
|
||||
ProviderAudioQuality.Any,
|
||||
headOnly: false,
|
||||
() => Task.FromResult<Song?>(new Song
|
||||
{
|
||||
@@ -80,9 +90,9 @@ public sealed class ManagedTrackCacheServiceTests
|
||||
|
||||
await service.WrapAsync(
|
||||
ProviderStream(response),
|
||||
Context(),
|
||||
"deezer",
|
||||
"track-2",
|
||||
ProviderAudioQuality.Any,
|
||||
headOnly: false,
|
||||
() => Task.FromResult<Song?>(null),
|
||||
CancellationToken.None);
|
||||
@@ -94,7 +104,7 @@ public sealed class ManagedTrackCacheServiceTests
|
||||
? Directory.GetFiles(root, "*", SearchOption.AllDirectories)
|
||||
: []);
|
||||
local.Verify(item => item.RegisterDownloadedSongAsync(
|
||||
It.IsAny<Song>(), It.IsAny<string>()), Times.Never);
|
||||
It.IsAny<DownloadedSongMappingScope>(), It.IsAny<Song>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -120,9 +130,9 @@ public sealed class ManagedTrackCacheServiceTests
|
||||
|
||||
await service.WrapAsync(
|
||||
ProviderStream(response),
|
||||
Context(),
|
||||
"deezer",
|
||||
"track-3",
|
||||
ProviderAudioQuality.Any,
|
||||
headOnly: false,
|
||||
() => Task.FromResult<Song?>(null),
|
||||
CancellationToken.None);
|
||||
@@ -178,6 +188,24 @@ public sealed class ManagedTrackCacheServiceTests
|
||||
ProviderStreamRetryBehavior.DoNotRetry),
|
||||
"deezer");
|
||||
|
||||
private static ProtocolExecutionContext Context() => new(
|
||||
ProtocolKind.Jellyfin,
|
||||
"backend",
|
||||
"principal",
|
||||
new AllstarrPrincipal(
|
||||
Guid.Parse("10000000-0000-0000-0000-000000000001"),
|
||||
Guid.Parse("20000000-0000-0000-0000-000000000001"),
|
||||
"jellyfin",
|
||||
"backend",
|
||||
"principal",
|
||||
"User",
|
||||
false),
|
||||
"cache-test",
|
||||
DateTimeOffset.UtcNow.AddMinutes(1),
|
||||
CancellationToken.None,
|
||||
new ProtocolClientDescriptor("client", "device"),
|
||||
libraryScopeId: "music");
|
||||
|
||||
private static string CreateRoot()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -91,6 +91,83 @@ public sealed class MusicBrainzServiceTests
|
||||
Assert.DoesNotContain(isrcHandler.RequestUris, uri => uri.Query.Contains("recording", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrainzMashSource_IsolatedAndLoadsCanonicalHierarchy()
|
||||
{
|
||||
const string artistId = "11e68c1d-31f9-432c-a3a4-13aef4a53833";
|
||||
const string releaseGroupId = "21e68c1d-31f9-432c-a3a4-13aef4a53833";
|
||||
const string releaseId = "31e68c1d-31f9-432c-a3a4-13aef4a53833";
|
||||
var handler = new QueueHandler(
|
||||
_ => Json($$$"""{"id":"{{{artistId}}}","name":"Artist","sort-name":"Artist"}"""),
|
||||
_ => Json($$$"""{"id":"{{{releaseGroupId}}}","title":"Album","primary-type":"Album","artist-credit":[{"name":"Artist","artist":{"id":"{{{artistId}}}","name":"Artist"}}]}"""),
|
||||
_ => Json($$$"""{"id":"{{{releaseId}}}","title":"Album","release-group":{"id":"{{{releaseGroupId}}}","title":"Album"},"media":[{"position":1,"track-count":1,"tracks":[{"id":"41e68c1d-31f9-432c-a3a4-13aef4a53833","position":1,"title":"Song","recording":{"id":"51e68c1d-31f9-432c-a3a4-13aef4a53833","title":"Song"}}]}]}"""));
|
||||
var service = Create(
|
||||
new RecordingFactory(handler),
|
||||
new MusicBrainzSettings
|
||||
{
|
||||
Enabled = true,
|
||||
SourceId = "BrainzMash",
|
||||
BaseUrl = "https://api.brainzmash.test/ws/2",
|
||||
RateLimitMs = 1000
|
||||
});
|
||||
|
||||
Assert.Equal("Artist", (await service.LookupArtistByMbidAsync(artistId))!.Name);
|
||||
Assert.Equal("Album", (await service.LookupReleaseGroupByMbidAsync(releaseGroupId))!.Title);
|
||||
var release = await service.LookupReleaseByMbidAsync(releaseId);
|
||||
|
||||
Assert.Equal("brainzmash", service.ConfiguredSourceId);
|
||||
Assert.Equal("brainzmash:ws2", service.ConfiguredSourceRevision);
|
||||
Assert.Equal("Song", Assert.Single(Assert.Single(release!.Media!).Tracks!).Recording!.Title);
|
||||
Assert.Collection(
|
||||
handler.RequestUris,
|
||||
uri => Assert.EndsWith($"/artist/{artistId}", uri.AbsolutePath, StringComparison.Ordinal),
|
||||
uri => Assert.EndsWith($"/release-group/{releaseGroupId}", uri.AbsolutePath, StringComparison.Ordinal),
|
||||
uri => Assert.EndsWith($"/release/{releaseId}", uri.AbsolutePath, StringComparison.Ordinal));
|
||||
Assert.All(handler.RequestUris, uri => Assert.Equal("api.brainzmash.test", uri.Host));
|
||||
Assert.Equal(
|
||||
$"Allstarr/{AppVersion.Version} (+https://github.com/SoPat712/allstarr)",
|
||||
MusicBrainzService.UserAgent);
|
||||
Assert.All(handler.UserAgents, userAgent => Assert.Equal(MusicBrainzService.UserAgent, userAgent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizedUserAgentOverride_ReplacesFactoryHeaderWithoutChangingSourceIdentity()
|
||||
{
|
||||
const string temporaryIdentity = "DroppedNeedleApp/backend-test";
|
||||
var handler = new QueueHandler(_ => Json("""{"recordings":[]}"""));
|
||||
var factory = new RecordingFactory(handler);
|
||||
factory.Client.DefaultRequestHeaders.UserAgent.ParseAdd("stale/header");
|
||||
var service = Create(
|
||||
factory,
|
||||
new MusicBrainzSettings
|
||||
{
|
||||
Enabled = true,
|
||||
SourceId = "brainzmash",
|
||||
BaseUrl = "https://api.brainzmash.test/ws/2",
|
||||
AuthorizedUserAgentOverride = temporaryIdentity
|
||||
});
|
||||
|
||||
Assert.Empty(await service.SearchRecordingsAsync("Missing song", "Missing artist"));
|
||||
|
||||
Assert.Equal(temporaryIdentity, service.ConfiguredUserAgent);
|
||||
Assert.Equal("brainzmash:ws2", service.ConfiguredSourceRevision);
|
||||
Assert.Equal(temporaryIdentity, Assert.Single(handler.UserAgents));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("bad\rheader")]
|
||||
[InlineData("bad\nheader")]
|
||||
public void AuthorizedUserAgentOverride_RejectsHeaderInjection(string value)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => Create(
|
||||
new RecordingFactory(new QueueHandler(_ => Json("{}"))),
|
||||
new MusicBrainzSettings
|
||||
{
|
||||
Enabled = true,
|
||||
AuthorizedUserAgentOverride = value
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AmbiguousTextCandidatesRemainUnresolvedWithoutDetailLookup()
|
||||
{
|
||||
@@ -132,7 +209,7 @@ public sealed class MusicBrainzServiceTests
|
||||
Create(new RecordingFactory(new QueueHandler(_ => oversized)))
|
||||
.LookupByMbidAsync("31e68c1d-31f9-432c-a3a4-13aef4a53833"));
|
||||
Assert.False(oversizedError.Retryable);
|
||||
Assert.Equal("musicbrainz_response_too_large", oversizedError.Code);
|
||||
Assert.Equal("catalog_source_response_too_large", oversizedError.Code);
|
||||
|
||||
var unavailable = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
|
||||
unavailable.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(7));
|
||||
@@ -140,9 +217,32 @@ public sealed class MusicBrainzServiceTests
|
||||
Create(new RecordingFactory(new QueueHandler(_ => unavailable)))
|
||||
.LookupByMbidAsync("41e68c1d-31f9-432c-a3a4-13aef4a53833"));
|
||||
Assert.True(retryError.Retryable);
|
||||
Assert.Equal("catalog_source_temporarily_unavailable", retryError.Code);
|
||||
Assert.Equal(TimeSpan.FromSeconds(7), retryError.RetryAfter);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
public async Task CatalogSourceAccessDenial_IsExplicitAndNotRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
var service = Create(
|
||||
new RecordingFactory(new QueueHandler(_ => new HttpResponseMessage(statusCode))),
|
||||
new MusicBrainzSettings
|
||||
{
|
||||
Enabled = true,
|
||||
SourceId = "brainzmash",
|
||||
BaseUrl = "https://api.brainzmash.test/ws/2"
|
||||
});
|
||||
|
||||
var error = await Assert.ThrowsAsync<MusicBrainzLookupException>(() =>
|
||||
service.LookupByMbidAsync("31e68c1d-31f9-432c-a3a4-13aef4a53833"));
|
||||
|
||||
Assert.Equal("catalog_source_access_denied", error.Code);
|
||||
Assert.False(error.Retryable);
|
||||
Assert.Contains("brainzmash", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdenticalLookupsAreCoalescedAndCancellationIsPropagated()
|
||||
{
|
||||
@@ -213,9 +313,28 @@ public sealed class MusicBrainzServiceTests
|
||||
Assert.Equal("Accepted title", occurrence.Title);
|
||||
}
|
||||
|
||||
private static MusicBrainzService Create(RecordingFactory factory) => new(
|
||||
[Fact]
|
||||
public void DurableResult_PreservesConfiguredCatalogSourceRevision()
|
||||
{
|
||||
var occurrence = new ListeningEventRecord();
|
||||
|
||||
MusicBrainzListeningEnrichmentJobHandler.ApplyResult(
|
||||
occurrence,
|
||||
new(new MusicBrainzRecording
|
||||
{
|
||||
Id = "31e68c1d-31f9-432c-a3a4-13aef4a53833",
|
||||
Title = "Song"
|
||||
}, 1, "brainzmash:ws2"),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("brainzmash:ws2", occurrence.MusicBrainzSourceRevision);
|
||||
}
|
||||
|
||||
private static MusicBrainzService Create(
|
||||
RecordingFactory factory,
|
||||
MusicBrainzSettings? settings = null) => new(
|
||||
factory,
|
||||
Options.Create(new MusicBrainzSettings
|
||||
Options.Create(settings ?? new MusicBrainzSettings
|
||||
{
|
||||
Enabled = true,
|
||||
BaseUrl = "https://musicbrainz.test/ws/2",
|
||||
|
||||
@@ -76,9 +76,18 @@ public class CacheKeyBuilderTests
|
||||
[Fact]
|
||||
public void MusicBrainzAndOdesliKeys_ShouldMatchExpectedFormats()
|
||||
{
|
||||
Assert.Equal("musicbrainz:isrc:v2:usabc123", CacheKeyBuilder.BuildMusicBrainzIsrcKey("USABC123"));
|
||||
Assert.StartsWith("musicbrainz:search:v2:", CacheKeyBuilder.BuildMusicBrainzSearchKey("Title", "Artist", 5));
|
||||
Assert.Equal("musicbrainz:mbid:v2:abc-def", CacheKeyBuilder.BuildMusicBrainzMbidKey("abc-def"));
|
||||
Assert.Equal(
|
||||
"musicbrainz:isrc:v3:musicbrainz:usabc123",
|
||||
CacheKeyBuilder.BuildMusicBrainzIsrcKey("USABC123"));
|
||||
Assert.StartsWith(
|
||||
"musicbrainz:search:v3:musicbrainz:",
|
||||
CacheKeyBuilder.BuildMusicBrainzSearchKey("Title", "Artist", 5));
|
||||
Assert.Equal(
|
||||
"musicbrainz:recording:v3:musicbrainz:abc-def",
|
||||
CacheKeyBuilder.BuildMusicBrainzMbidKey("abc-def"));
|
||||
Assert.NotEqual(
|
||||
CacheKeyBuilder.BuildMusicBrainzMbidKey("abc-def"),
|
||||
CacheKeyBuilder.BuildMusicBrainzMbidKey("abc-def", "brainzmash"));
|
||||
|
||||
Assert.StartsWith("odesli:tidal-to-spotify:v2:", CacheKeyBuilder.BuildOdesliTidalToSpotifyKey("123"));
|
||||
var urlKey = CacheKeyBuilder.BuildOdesliUrlToSpotifyKey("https://example.com/track?token=secret");
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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 secondUserId = Guid.CreateVersion7();
|
||||
var backendIdentityId = Guid.CreateVersion7();
|
||||
var secondBackendIdentityId = 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 PlatformUserRecord
|
||||
{
|
||||
Id = secondUserId,
|
||||
TenantId = tenantId,
|
||||
DisplayName = "Second catalog user",
|
||||
Status = PlatformUserStatus.Active,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
},
|
||||
new BackendIdentityRecord
|
||||
{
|
||||
Id = backendIdentityId,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
BackendType = "jellyfin",
|
||||
BackendInstanceId = "home",
|
||||
PrincipalId = "catalog-owner",
|
||||
DisplayName = "Catalog owner",
|
||||
CreatedAt = now,
|
||||
LastSeenAt = now
|
||||
},
|
||||
new BackendIdentityRecord
|
||||
{
|
||||
Id = secondBackendIdentityId,
|
||||
TenantId = tenantId,
|
||||
UserId = secondUserId,
|
||||
BackendType = "jellyfin",
|
||||
BackendInstanceId = "home",
|
||||
PrincipalId = "second-catalog-user",
|
||||
DisplayName = "Second catalog user",
|
||||
CreatedAt = now,
|
||||
LastSeenAt = 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),
|
||||
LibraryTrack(userId, backendIdentityId, "owner.flac"),
|
||||
LibraryTrack(secondUserId, secondBackendIdentityId, "second.flac"));
|
||||
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(5, 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);
|
||||
Assert.Contains(aliases, alias =>
|
||||
alias.Namespace == CanonicalCatalogKeys.NativeTrackNamespace("jellyfin", "home") &&
|
||||
alias.ExternalId == "native-item" &&
|
||||
alias.CanonicalEntityId == provisionalRecordingId);
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
LibraryTrackRecord LibraryTrack(
|
||||
Guid ownerUserId,
|
||||
Guid backendIdentity,
|
||||
string fileName) => new()
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = tenantId,
|
||||
OwnerUserId = ownerUserId,
|
||||
BackendIdentityId = backendIdentity,
|
||||
CanonicalRecordingId = provisionalRecordingId,
|
||||
LibraryScopeId = "music",
|
||||
Protocol = "jellyfin",
|
||||
BackendInstanceId = "home",
|
||||
BackendItemId = "native-item",
|
||||
FilePath = $"/media/{fileName}",
|
||||
Title = "Provider-only recording",
|
||||
Artist = "Fixture artist",
|
||||
ProviderIdsJson = "{}",
|
||||
IndexedAt = now,
|
||||
SourceModifiedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Services.MusicBrainz;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public sealed class CanonicalCatalogStorageTests : IAsyncLifetime
|
||||
{
|
||||
private PostgresTestDatabase _database = null!;
|
||||
|
||||
public async Task InitializeAsync() => _database = await PostgresTestDatabase.CreateAsync();
|
||||
|
||||
[Fact]
|
||||
public async Task CanonicalGraph_PersistsRecordingCreditsEditionTrackAliasAndProvenance()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 9, 14, 20, 0, 0, TimeSpan.Zero);
|
||||
var tenantId = Guid.CreateVersion7();
|
||||
var userId = Guid.CreateVersion7();
|
||||
var artistId = Guid.CreateVersion7();
|
||||
var recordingId = Guid.CreateVersion7();
|
||||
var releaseGroupId = Guid.CreateVersion7();
|
||||
var releaseId = Guid.CreateVersion7();
|
||||
var releaseTrackId = Guid.CreateVersion7();
|
||||
|
||||
await using (var db = new AllstarrDbContext(_database.Options))
|
||||
{
|
||||
db.Tenants.Add(new TenantRecord
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = "catalog-graph",
|
||||
Name = "Catalog graph",
|
||||
CreatedAt = now
|
||||
});
|
||||
db.Users.Add(new PlatformUserRecord
|
||||
{
|
||||
Id = userId,
|
||||
TenantId = tenantId,
|
||||
DisplayName = "Catalog owner",
|
||||
Status = PlatformUserStatus.Active,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
db.CanonicalArtists.Add(new CanonicalArtistRecord
|
||||
{
|
||||
Id = artistId,
|
||||
TenantId = tenantId,
|
||||
Name = "Example Artist",
|
||||
SortName = "Example Artist",
|
||||
MusicBrainzArtistId = "11111111-1111-1111-1111-111111111111",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
db.CanonicalRecordings.Add(new CanonicalRecordingRecord
|
||||
{
|
||||
Id = recordingId,
|
||||
TenantId = tenantId,
|
||||
CreatedByUserId = userId,
|
||||
Title = "Example Recording",
|
||||
DurationMilliseconds = 183_000,
|
||||
MusicBrainzRecordingId = "22222222-2222-2222-2222-222222222222",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
db.CanonicalReleaseGroups.Add(new CanonicalReleaseGroupRecord
|
||||
{
|
||||
Id = releaseGroupId,
|
||||
TenantId = tenantId,
|
||||
Title = "Example Album",
|
||||
PrimaryType = "Album",
|
||||
MusicBrainzReleaseGroupId = "33333333-3333-3333-3333-333333333333",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
db.CanonicalReleases.Add(new CanonicalReleaseRecord
|
||||
{
|
||||
Id = releaseId,
|
||||
TenantId = tenantId,
|
||||
CanonicalReleaseGroupId = releaseGroupId,
|
||||
Title = "Example Album",
|
||||
CountryCode = "US",
|
||||
ReleaseDate = "2026-09-14",
|
||||
MusicBrainzReleaseId = "44444444-4444-4444-4444-444444444444",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
db.CanonicalReleaseTracks.Add(new CanonicalReleaseTrackRecord
|
||||
{
|
||||
Id = releaseTrackId,
|
||||
TenantId = tenantId,
|
||||
CanonicalReleaseId = releaseId,
|
||||
CanonicalRecordingId = recordingId,
|
||||
MediumPosition = 1,
|
||||
TrackPosition = 1,
|
||||
Title = "Example Recording",
|
||||
DurationMilliseconds = 183_000,
|
||||
MusicBrainzTrackId = "55555555-5555-5555-5555-555555555555",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
db.CanonicalRecordingArtists.Add(new CanonicalRecordingArtistRecord
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CanonicalRecordingId = recordingId,
|
||||
CanonicalArtistId = artistId,
|
||||
Position = 0
|
||||
});
|
||||
db.CanonicalReleaseGroupArtists.Add(new CanonicalReleaseGroupArtistRecord
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CanonicalReleaseGroupId = releaseGroupId,
|
||||
CanonicalArtistId = artistId,
|
||||
Position = 0
|
||||
});
|
||||
db.CanonicalCatalogAliases.Add(new CanonicalCatalogAliasRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = tenantId,
|
||||
EntityKind = CanonicalCatalogEntityKind.Recording,
|
||||
CanonicalEntityId = recordingId,
|
||||
Namespace = "legacy-protocol-id",
|
||||
ExternalId = "ext-spotify-example",
|
||||
ExternalIdHash = new string('a', 64),
|
||||
CreatedAt = now,
|
||||
LastSeenAt = now
|
||||
});
|
||||
db.CatalogFacts.Add(new CatalogFactRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = tenantId,
|
||||
EntityKind = CanonicalCatalogEntityKind.Recording,
|
||||
CanonicalEntityId = recordingId,
|
||||
FieldName = "title",
|
||||
ValueJson = "\"Example Recording\"",
|
||||
SourceId = "fixture",
|
||||
SourceRevision = "fixture-1",
|
||||
PayloadSha256 = new string('b', 64),
|
||||
Confidence = 1,
|
||||
ObservedAt = now,
|
||||
RefreshAfter = now.AddDays(7)
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var verification = new AllstarrDbContext(_database.Options);
|
||||
var track = await verification.CanonicalReleaseTracks.SingleAsync();
|
||||
Assert.Equal(recordingId, track.CanonicalRecordingId);
|
||||
Assert.Equal(releaseId, track.CanonicalReleaseId);
|
||||
Assert.Equal(artistId, (await verification.CanonicalRecordingArtists.SingleAsync()).CanonicalArtistId);
|
||||
Assert.Equal(recordingId, (await verification.CanonicalCatalogAliases.SingleAsync()).CanonicalEntityId);
|
||||
var fact = await verification.CatalogFacts.SingleAsync();
|
||||
Assert.Equal("fixture", fact.SourceId);
|
||||
|
||||
fact.SourceId = "rewritten-source";
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => verification.SaveChangesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvidenceStore_IsIdempotentSupersedesFactsAndRejectsAliasRemapping()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 9, 14, 21, 0, 0, TimeSpan.Zero);
|
||||
var tenantId = Guid.CreateVersion7();
|
||||
var userId = Guid.CreateVersion7();
|
||||
var recordingId = Guid.CreateVersion7();
|
||||
var otherRecordingId = Guid.CreateVersion7();
|
||||
await using (var db = new AllstarrDbContext(_database.Options))
|
||||
{
|
||||
db.Tenants.Add(new TenantRecord
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = "catalog-evidence",
|
||||
Name = "Catalog evidence",
|
||||
CreatedAt = now
|
||||
});
|
||||
db.Users.Add(new PlatformUserRecord
|
||||
{
|
||||
Id = userId,
|
||||
TenantId = tenantId,
|
||||
DisplayName = "Catalog owner",
|
||||
Status = PlatformUserStatus.Active,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
db.CanonicalRecordings.AddRange(
|
||||
Recording(recordingId, tenantId, userId, "First", now),
|
||||
Recording(otherRecordingId, tenantId, userId, "Second", now));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var storage = new DurableStorageOptions
|
||||
{
|
||||
Provider = "Postgres",
|
||||
ConnectionString = _database.ConnectionString
|
||||
};
|
||||
var storageState = new DurableStorageState(storage);
|
||||
storageState.Set(DurableStorageReadiness.Ready, "fixture");
|
||||
var store = new CanonicalCatalogEvidenceStore(
|
||||
new TestDbContextFactory(_database.Options),
|
||||
storageState);
|
||||
var actor = new ProviderActorContext(
|
||||
tenantId,
|
||||
ProviderActorKind.User,
|
||||
userId,
|
||||
new ProviderBackendPrincipal("jellyfin", "fixture", "catalog-user"));
|
||||
var target = new CanonicalCatalogEntityReference(
|
||||
CanonicalCatalogEntityKind.Recording,
|
||||
recordingId);
|
||||
var alias = new CanonicalCatalogAliasInput("Legacy-Protocol-ID", "ext-spotify-track");
|
||||
var initial = new CanonicalCatalogSourceStamp(
|
||||
"Fixture",
|
||||
"revision-1",
|
||||
new string('a', 64),
|
||||
0.9,
|
||||
now,
|
||||
now.AddDays(7));
|
||||
|
||||
var first = await store.RecordAsync(
|
||||
actor,
|
||||
target,
|
||||
initial,
|
||||
[alias],
|
||||
[new CanonicalCatalogFactInput("title", " \"First\" ")]);
|
||||
var repeated = await store.RecordAsync(
|
||||
actor,
|
||||
target,
|
||||
initial,
|
||||
[alias],
|
||||
[new CanonicalCatalogFactInput("title", "\"First\"")]);
|
||||
var changed = await store.RecordAsync(
|
||||
actor,
|
||||
target,
|
||||
initial with
|
||||
{
|
||||
SourceRevision = "revision-2",
|
||||
PayloadSha256 = new string('b', 64),
|
||||
ObservedAt = now.AddDays(1),
|
||||
RefreshAfter = now.AddDays(8)
|
||||
},
|
||||
[alias],
|
||||
[new CanonicalCatalogFactInput("title", "\"First (Remastered)\"")]);
|
||||
|
||||
Assert.Equal(new CanonicalCatalogEvidenceResult(1, 0, 1, 0), first);
|
||||
Assert.Equal(new CanonicalCatalogEvidenceResult(0, 1, 0, 0), repeated);
|
||||
Assert.Equal(new CanonicalCatalogEvidenceResult(0, 1, 1, 1), changed);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => store.RecordAsync(
|
||||
actor,
|
||||
target with { Id = otherRecordingId },
|
||||
initial,
|
||||
[alias],
|
||||
[]));
|
||||
|
||||
await using var verification = new AllstarrDbContext(_database.Options);
|
||||
Assert.Single(await verification.CanonicalCatalogAliases.ToListAsync());
|
||||
var facts = await verification.CatalogFacts.OrderBy(item => item.ObservedAt).ToListAsync();
|
||||
Assert.Equal(2, facts.Count);
|
||||
Assert.NotNull(facts[0].SupersededAt);
|
||||
Assert.Null(facts[1].SupersededAt);
|
||||
Assert.Equal("fixture", facts[1].SourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrainzMashGraphIngest_IsAtomicIdempotentAndPreservesEditionIdentity()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 9, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var tenantId = Guid.CreateVersion7();
|
||||
var userId = Guid.CreateVersion7();
|
||||
await SeedActorAsync(tenantId, userId, "brainzmash-ingest", now);
|
||||
var service = new MusicBrainzCatalogIngestService(
|
||||
new TestDbContextFactory(_database.Options),
|
||||
ReadyStorage());
|
||||
var actor = Actor(tenantId, userId);
|
||||
var artist = new MusicBrainzArtist
|
||||
{
|
||||
Id = "11111111-1111-4111-8111-111111111111",
|
||||
Name = "Nicky Youre",
|
||||
SortName = "Youre, Nicky"
|
||||
};
|
||||
var credit = new MusicBrainzArtistCredit { Name = artist.Name, Artist = artist };
|
||||
var releaseGroup = new MusicBrainzReleaseGroup
|
||||
{
|
||||
Id = "22222222-2222-4222-8222-222222222222",
|
||||
Title = "Sunroof",
|
||||
PrimaryType = "Single",
|
||||
FirstReleaseDate = "2021-12-03",
|
||||
ArtistCredit = [credit]
|
||||
};
|
||||
var release = new MusicBrainzRelease
|
||||
{
|
||||
Id = "33333333-3333-4333-8333-333333333333",
|
||||
Title = "Sunroof",
|
||||
Date = "2021-12-03",
|
||||
Country = "US",
|
||||
Status = "Official",
|
||||
ReleaseGroup = releaseGroup,
|
||||
ArtistCredit = [credit],
|
||||
Media =
|
||||
[
|
||||
new MusicBrainzMedium
|
||||
{
|
||||
Position = 1,
|
||||
TrackCount = 2,
|
||||
Tracks =
|
||||
[
|
||||
Track(
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
"55555555-5555-4555-8555-555555555555",
|
||||
1,
|
||||
"Sunroof",
|
||||
163_000,
|
||||
credit),
|
||||
Track(
|
||||
"66666666-6666-4666-8666-666666666666",
|
||||
"77777777-7777-4777-8777-777777777777",
|
||||
2,
|
||||
"Sunroof (Acoustic)",
|
||||
135_000,
|
||||
credit)
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
var graph = new MusicBrainzCatalogGraph(release, releaseGroup, [artist]);
|
||||
var source = new MusicBrainzCatalogSource(
|
||||
"BrainzMash", "brainzmash:ws2", now, now.AddDays(7));
|
||||
|
||||
var first = await service.IngestAsync(actor, graph, source);
|
||||
var repeated = await service.IngestAsync(
|
||||
actor,
|
||||
graph,
|
||||
source with { ObservedAt = now.AddDays(1), RefreshAfter = now.AddDays(8) });
|
||||
|
||||
Assert.Equal(first.ReleaseGroupId, repeated.ReleaseGroupId);
|
||||
Assert.Equal(first.ReleaseId, repeated.ReleaseId);
|
||||
Assert.Equal(7, first.EntitiesCreated);
|
||||
Assert.Equal(0, repeated.EntitiesCreated);
|
||||
Assert.Equal(7, first.Evidence.AliasesCreated);
|
||||
Assert.Equal(7, first.Evidence.FactsCreated);
|
||||
Assert.Equal(7, repeated.Evidence.AliasesSeen);
|
||||
Assert.Equal(0, repeated.Evidence.FactsCreated);
|
||||
Assert.Equal(0, repeated.Evidence.FactsSuperseded);
|
||||
|
||||
await using (var verification = new AllstarrDbContext(_database.Options))
|
||||
{
|
||||
Assert.Single(await verification.CanonicalArtists.Where(item => item.TenantId == tenantId).ToListAsync());
|
||||
Assert.Single(await verification.CanonicalReleaseGroups.Where(item => item.TenantId == tenantId).ToListAsync());
|
||||
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(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));
|
||||
}
|
||||
|
||||
var invalidTenantId = Guid.CreateVersion7();
|
||||
var invalidUserId = Guid.CreateVersion7();
|
||||
await SeedActorAsync(invalidTenantId, invalidUserId, "brainzmash-invalid", now);
|
||||
release.Media![0].Tracks![1].Position = 1;
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.IngestAsync(
|
||||
Actor(invalidTenantId, invalidUserId), graph, source));
|
||||
await using var atomicVerification = new AllstarrDbContext(_database.Options);
|
||||
Assert.Equal(0, await atomicVerification.CanonicalReleaseGroups.CountAsync(
|
||||
item => item.TenantId == invalidTenantId));
|
||||
Assert.Equal(0, await atomicVerification.CanonicalRecordings.CountAsync(
|
||||
item => item.TenantId == invalidTenantId));
|
||||
Assert.Equal(0, await atomicVerification.CatalogFacts.CountAsync(
|
||||
item => item.TenantId == invalidTenantId));
|
||||
}
|
||||
|
||||
private static MusicBrainzReleaseTrack Track(
|
||||
string trackId,
|
||||
string recordingId,
|
||||
int position,
|
||||
string title,
|
||||
int duration,
|
||||
MusicBrainzArtistCredit credit) => new()
|
||||
{
|
||||
Id = trackId,
|
||||
Position = position,
|
||||
Title = title,
|
||||
Length = duration,
|
||||
ArtistCredit = [credit],
|
||||
Recording = new MusicBrainzRecording
|
||||
{
|
||||
Id = recordingId,
|
||||
Title = title,
|
||||
Length = duration,
|
||||
ArtistCredit = [credit]
|
||||
}
|
||||
};
|
||||
|
||||
private async Task SeedActorAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
string slug,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
await using var db = new AllstarrDbContext(_database.Options);
|
||||
db.Tenants.Add(new TenantRecord
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = slug,
|
||||
Name = slug,
|
||||
CreatedAt = now
|
||||
});
|
||||
db.Users.Add(new PlatformUserRecord
|
||||
{
|
||||
Id = userId,
|
||||
TenantId = tenantId,
|
||||
DisplayName = "Catalog owner",
|
||||
Status = PlatformUserStatus.Active,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static ProviderActorContext Actor(Guid tenantId, Guid userId) => new(
|
||||
tenantId,
|
||||
ProviderActorKind.User,
|
||||
userId,
|
||||
new ProviderBackendPrincipal("jellyfin", "fixture", "catalog-user"));
|
||||
|
||||
private DurableStorageState ReadyStorage()
|
||||
{
|
||||
var state = new DurableStorageState(new DurableStorageOptions
|
||||
{
|
||||
Provider = "Postgres",
|
||||
ConnectionString = _database.ConnectionString
|
||||
});
|
||||
state.Set(DurableStorageReadiness.Ready, "fixture");
|
||||
return state;
|
||||
}
|
||||
|
||||
private static CanonicalRecordingRecord Recording(
|
||||
Guid id,
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
string title,
|
||||
DateTimeOffset now) => new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
CreatedByUserId = userId,
|
||||
Title = title,
|
||||
IsProvisional = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
private sealed class TestDbContextFactory(DbContextOptions<AllstarrDbContext> options)
|
||||
: IDbContextFactory<AllstarrDbContext>
|
||||
{
|
||||
public AllstarrDbContext CreateDbContext() => new(options);
|
||||
|
||||
public Task<AllstarrDbContext> CreateDbContextAsync(
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(CreateDbContext());
|
||||
}
|
||||
|
||||
public async Task DisposeAsync() => await _database.DisposeAsync();
|
||||
}
|
||||
@@ -14,6 +14,6 @@ public sealed class MigrationModelSnapshotTests
|
||||
using var context = new AllstarrDbContext(options);
|
||||
|
||||
Assert.False(context.Database.HasPendingModelChanges());
|
||||
Assert.Equal("20260825020000_EnforcePlaylistImportPolicies", context.Database.GetMigrations().Last());
|
||||
Assert.Equal("20260922000030_ProjectNativeTrackAliases", context.Database.GetMigrations().Last());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Configuration;
|
||||
using allstarr.Core.Downloads;
|
||||
using allstarr.Core.Favorites;
|
||||
using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Identity;
|
||||
@@ -26,6 +27,46 @@ namespace allstarr.Tests;
|
||||
|
||||
public sealed class PostgresStorageIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "Postgres")]
|
||||
public async Task DownloadedTrackCache_SeparatesTenantsAccountsLibrariesAndQuality()
|
||||
{
|
||||
await using var database = await PostgresTestDatabase.CreateAsync();
|
||||
var store = new EfDownloadedSongMappingStore(new TestDbContextFactory(database.Options));
|
||||
var tenant = Guid.CreateVersion7();
|
||||
var otherTenant = Guid.CreateVersion7();
|
||||
var account = Guid.CreateVersion7();
|
||||
var scope = new DownloadedSongMappingScope(
|
||||
tenant, account, "music", ProviderAudioQuality.Lossless);
|
||||
await store.UpsertAsync(new DownloadedSongMappingEntity
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
ScopeKey = scope.Key,
|
||||
TenantId = tenant,
|
||||
ProviderAccountId = account,
|
||||
LibraryScopeId = "music",
|
||||
AudioQuality = ProviderAudioQuality.Lossless,
|
||||
ProviderId = "deezer",
|
||||
ExternalId = "track",
|
||||
LocalPath = "/managed/cache/track.flac",
|
||||
Title = "Track",
|
||||
Artist = "Artist",
|
||||
Album = "Album",
|
||||
DownloadedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
Assert.NotNull(await store.FindAsync(scope, "deezer", "track"));
|
||||
Assert.Null(await store.FindAsync(
|
||||
scope with { TenantId = otherTenant }, "deezer", "track"));
|
||||
Assert.Null(await store.FindAsync(
|
||||
scope with { ProviderAccountId = Guid.CreateVersion7() }, "deezer", "track"));
|
||||
Assert.Null(await store.FindAsync(
|
||||
scope with { LibraryScopeId = "other" }, "deezer", "track"));
|
||||
Assert.Null(await store.FindAsync(
|
||||
scope with { AudioQuality = ProviderAudioQuality.HighResolution }, "deezer", "track"));
|
||||
Assert.Null(await store.FindAsync("deezer", "track"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Postgres")]
|
||||
public async Task NativePostgresLineageConstraints_RejectCrossTenantFavoriteJob()
|
||||
|
||||
+3
-2
@@ -200,9 +200,10 @@ update() {
|
||||
if [[ "$(deployment_mode)" == source ]]; then
|
||||
need git
|
||||
[[ -d "$ROOT/.git" ]] || die "source mode requires a Git checkout"
|
||||
git diff --quiet && git diff --cached --quiet ||
|
||||
git -c "safe.directory=$ROOT" diff --quiet &&
|
||||
git -c "safe.directory=$ROOT" diff --cached --quiet ||
|
||||
die "tracked source files have local changes; commit or stash them before updating"
|
||||
git pull --ff-only
|
||||
git -c "safe.directory=$ROOT" pull --ff-only
|
||||
docker image prune --force
|
||||
docker builder prune --force --min-free-space 8GB
|
||||
docker compose "${COMPOSE[@]}" build allstarr
|
||||
|
||||
@@ -12,6 +12,12 @@ public static class AppVersion
|
||||
public const string Version = "3.1.0-beta.1";
|
||||
}
|
||||
|
||||
public static class AppIdentity
|
||||
{
|
||||
public const string RepositoryUrl = "https://github.com/SoPat712/allstarr";
|
||||
public static readonly string UserAgent = $"Allstarr/{AppVersion.Version} (+{RepositoryUrl})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility retained for v3.0. Removal requires an explicit release decision no earlier than v3.1.
|
||||
/// </summary>
|
||||
|
||||
@@ -7,6 +7,7 @@ using allstarr.Models.Settings;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Core.Identity;
|
||||
using allstarr.Core.Configuration;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
@@ -24,6 +25,7 @@ public sealed class AdminAuthController : ControllerBase
|
||||
private readonly BackendIdentityResolver? _identityResolver;
|
||||
private readonly ProviderAccountManagementMode _providerAccountManagementMode;
|
||||
private readonly IMediaAssetResolver _mediaAssets;
|
||||
private readonly ReleaseComposition _releaseComposition;
|
||||
|
||||
public AdminAuthController(
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
@@ -34,7 +36,8 @@ public sealed class AdminAuthController : ControllerBase
|
||||
ILogger<AdminAuthController> logger,
|
||||
IMediaAssetResolver mediaAssets,
|
||||
BackendIdentityResolver? identityResolver = null,
|
||||
ProviderAccountManagementOptions? providerAccountManagementOptions = null)
|
||||
ProviderAccountManagementOptions? providerAccountManagementOptions = null,
|
||||
ReleaseComposition? releaseComposition = null)
|
||||
{
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_subsonicSettings = subsonicSettings.Value;
|
||||
@@ -51,6 +54,7 @@ public sealed class AdminAuthController : ControllerBase
|
||||
_identityResolver = identityResolver;
|
||||
_providerAccountManagementMode = (providerAccountManagementOptions ?? new())
|
||||
.ParseManagementMode();
|
||||
_releaseComposition = releaseComposition ?? ReleaseComposition.Core;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
@@ -176,7 +180,8 @@ public sealed class AdminAuthController : ControllerBase
|
||||
{
|
||||
authenticated = false,
|
||||
backend = _backendType.ToString(),
|
||||
providerAccountManagementMode = _providerAccountManagementMode.ToString()
|
||||
providerAccountManagementMode = _providerAccountManagementMode.ToString(),
|
||||
features = ReleaseFeatures()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -459,9 +464,15 @@ public sealed class AdminAuthController : ControllerBase
|
||||
rememberMe = session.IsPersistent,
|
||||
backend = session.BackendType,
|
||||
providerAccountManagementMode = _providerAccountManagementMode.ToString(),
|
||||
features = ReleaseFeatures(),
|
||||
expiresAtUtc = session.ExpiresAtUtc
|
||||
};
|
||||
|
||||
private object ReleaseFeatures() => new
|
||||
{
|
||||
intelligence = _releaseComposition.IntelligenceEnabled
|
||||
};
|
||||
|
||||
private readonly record struct SubsonicIdentity(string UserName, bool IsAdministrator);
|
||||
|
||||
public sealed class LoginRequest
|
||||
|
||||
@@ -14,6 +14,7 @@ using allstarr.Services.Common;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Core.Settings;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -36,6 +37,7 @@ public class AdminUiController : ControllerBase
|
||||
private readonly ProviderAccountManagementMode _providerAccountManagementMode;
|
||||
private readonly IProviderRegistry? _providerRegistry;
|
||||
private readonly IProtocolProviderGateway? _providerGateway;
|
||||
private readonly IEffectiveProviderPolicyResolver? _effectivePolicies;
|
||||
private readonly ITrackMatchRepository _trackMatches;
|
||||
|
||||
public AdminUiController(
|
||||
@@ -50,7 +52,8 @@ public class AdminUiController : ControllerBase
|
||||
ProviderAccountManagementOptions providerAccountManagementOptions,
|
||||
ITrackMatchRepository trackMatches,
|
||||
IProviderRegistry? providerRegistry = null,
|
||||
IProtocolProviderGateway? providerGateway = null)
|
||||
IProtocolProviderGateway? providerGateway = null,
|
||||
IEffectiveProviderPolicyResolver? effectivePolicies = null)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
@@ -64,19 +67,22 @@ public class AdminUiController : ControllerBase
|
||||
_trackMatches = trackMatches;
|
||||
_providerRegistry = providerRegistry;
|
||||
_providerGateway = providerGateway;
|
||||
_effectivePolicies = effectivePolicies;
|
||||
}
|
||||
|
||||
[HttpGet("schema")]
|
||||
public IActionResult GetSchema()
|
||||
public async Task<IActionResult> GetSchema(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var activeBackend = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin";
|
||||
var policy = await TryEffectivePolicyAsync(cancellationToken);
|
||||
var disabledProviders = policy?.DisabledProviders;
|
||||
if (!IsAdministratorSession())
|
||||
{
|
||||
return Ok(new AdminUiSchemaResponse
|
||||
{
|
||||
ActiveBackend = activeBackend,
|
||||
ProviderAccountManagementMode = _providerAccountManagementMode.ToString(),
|
||||
Providers = BuildProviders().Select(item => new AdminUiProvider
|
||||
Providers = BuildProviders(disabledProviders).Select(item => new AdminUiProvider
|
||||
{
|
||||
Id = item.Id,
|
||||
Name = item.Name,
|
||||
@@ -107,10 +113,10 @@ public class AdminUiController : ControllerBase
|
||||
ProviderAccountManagementMode = _providerAccountManagementMode.ToString(),
|
||||
Routes = BuildRoutes(),
|
||||
Backends = BuildBackends(),
|
||||
Providers = BuildProviders(),
|
||||
Providers = BuildProviders(disabledProviders),
|
||||
ProviderSupportMatrix = CurrentProviderSupportCatalog.All.ToList(),
|
||||
MultiProviderCategories = ["metadata", "streaming", "download", "playlist", "lyrics", "enrichment"],
|
||||
PriorityGroups = BuildPriorityGroups(),
|
||||
PriorityGroups = BuildPriorityGroups(policy),
|
||||
ConfigSections = BuildConfigSections(),
|
||||
ExtensionStore = new AdminUiExtensionStore
|
||||
{
|
||||
@@ -155,6 +161,23 @@ public class AdminUiController : ControllerBase
|
||||
return Ok(schema);
|
||||
}
|
||||
|
||||
private async Task<EffectiveProviderPolicySnapshot?> TryEffectivePolicyAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_effectivePolicies == null ||
|
||||
!HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var value) ||
|
||||
value is not AdminAuthSession { TenantId: { } tenantId })
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return await _effectivePolicies.ResolveAsync(tenantId, cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("provider-summaries")]
|
||||
public async Task<IActionResult> GetProviderSummaries(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -224,7 +247,7 @@ public class AdminUiController : ControllerBase
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "A linked Allstarr user is required" });
|
||||
}
|
||||
|
||||
var schema = (GetSchema() as OkObjectResult)?.Value as AdminUiSchemaResponse;
|
||||
var schema = (await GetSchema(cancellationToken) as OkObjectResult)?.Value as AdminUiSchemaResponse;
|
||||
if (schema == null) return StatusCode(StatusCodes.Status500InternalServerError);
|
||||
var services = HttpContext.RequestServices;
|
||||
var contextFactory = services.GetRequiredService<IDbContextFactory<AllstarrDbContext>>();
|
||||
@@ -503,13 +526,22 @@ public class AdminUiController : ControllerBase
|
||||
? null
|
||||
: PlaylistArtworkUrl(link.ProviderAccountId, link.SourcePlaylistId));
|
||||
}));
|
||||
var effectivePolicy = _effectivePolicies == null
|
||||
? null
|
||||
: await _effectivePolicies.ResolveAsync(tenantId, cancellationToken);
|
||||
var routeProviderPriority = _providerGateway == null
|
||||
? null
|
||||
: _providerGateway.GetProviderOrder(ProviderCapabilityKind.Streaming)
|
||||
.Concat(_providerGateway.GetProviderOrder(ProviderCapabilityKind.Download))
|
||||
: ProviderOrder(ProviderCapabilityKind.Streaming)
|
||||
.Concat(ProviderOrder(ProviderCapabilityKind.Download))
|
||||
.Select(ExternalTrackPlaybackPolicy.Normalize)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
IReadOnlyList<string> ProviderOrder(ProviderCapabilityKind capability)
|
||||
{
|
||||
var available = _providerGateway.GetProviderOrder(capability);
|
||||
return effectivePolicy?.ApplyProviderAvailability(capability, available) ?? available;
|
||||
}
|
||||
activity.AddRange(matches.Select(item =>
|
||||
{
|
||||
externalSnapshots.TryGetValue(item.ExternalSnapshotId, out var snapshot);
|
||||
@@ -993,7 +1025,7 @@ public class AdminUiController : ControllerBase
|
||||
}
|
||||
];
|
||||
|
||||
private List<AdminUiProvider> BuildProviders()
|
||||
private List<AdminUiProvider> BuildProviders(IReadOnlySet<string>? disabledProviders = null)
|
||||
{
|
||||
List<AdminUiProvider> providers =
|
||||
[
|
||||
@@ -1004,7 +1036,7 @@ public class AdminUiController : ControllerBase
|
||||
Icon = "spotify",
|
||||
Status = ProviderStatus("spotify", _spotifyApiSettings.Enabled
|
||||
? (!string.IsNullOrWhiteSpace(_spotifyApiSettings.SessionCookie) ? "configured" : "needs_config")
|
||||
: "disabled"),
|
||||
: "disabled", disabledProviders),
|
||||
Categories = ["playlist", "lyrics"],
|
||||
ConfigSchema =
|
||||
[
|
||||
@@ -1018,7 +1050,7 @@ public class AdminUiController : ControllerBase
|
||||
Id = "apple-download",
|
||||
Name = "Apple Music – GAMDL",
|
||||
Icon = "applemusic",
|
||||
Status = ProviderStatus("apple-download", string.IsNullOrWhiteSpace(_appleMusicSettings.BaseUrl) ? "needs_config" : "unknown"),
|
||||
Status = ProviderStatus("apple-download", string.IsNullOrWhiteSpace(_appleMusicSettings.BaseUrl) ? "needs_config" : "unknown", disabledProviders),
|
||||
Categories = ["metadata", "streaming", "download", "lyrics"],
|
||||
ConnectionKind = "operator_managed",
|
||||
Audience = "everyone",
|
||||
@@ -1066,7 +1098,7 @@ public class AdminUiController : ControllerBase
|
||||
Id = "deezer",
|
||||
Name = "Deezer",
|
||||
Icon = "deezer",
|
||||
Status = ProviderStatus("deezer", string.IsNullOrWhiteSpace(_deezerSettings.Arl) ? "needs_config" : "configured"),
|
||||
Status = ProviderStatus("deezer", string.IsNullOrWhiteSpace(_deezerSettings.Arl) ? "needs_config" : "configured", disabledProviders),
|
||||
Categories = ["metadata", "download", "streaming", "playlist"],
|
||||
ConfigSchema =
|
||||
[
|
||||
@@ -1079,7 +1111,7 @@ public class AdminUiController : ControllerBase
|
||||
Name = "Qobuz",
|
||||
Icon = "qobuz",
|
||||
LogoUrl = "/images/providers/qobuz.webp",
|
||||
Status = ProviderStatus("qobuz", string.IsNullOrWhiteSpace(_qobuzSettings.UserAuthToken) ? "needs_config" : "configured"),
|
||||
Status = ProviderStatus("qobuz", string.IsNullOrWhiteSpace(_qobuzSettings.UserAuthToken) ? "needs_config" : "configured", disabledProviders),
|
||||
Categories = ["metadata", "download", "streaming", "playlist"],
|
||||
ConfigSchema =
|
||||
[
|
||||
@@ -1217,13 +1249,13 @@ public class AdminUiController : ControllerBase
|
||||
var statuses = runtimeStatuses
|
||||
.Where(status => status.Provider.Equals(provider.Id, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (statuses.Count == 0)
|
||||
if (statuses.Count > 0)
|
||||
{
|
||||
continue;
|
||||
provider.RuntimeCapabilities = statuses.Select(ToAdminRuntimeCapability).ToList();
|
||||
provider.Status = AggregateProviderStatus(statuses);
|
||||
}
|
||||
|
||||
provider.RuntimeCapabilities = statuses.Select(ToAdminRuntimeCapability).ToList();
|
||||
provider.Status = AggregateProviderStatus(statuses);
|
||||
if (disabledProviders?.Contains(provider.Id) == true)
|
||||
provider.Status = "disabled";
|
||||
}
|
||||
|
||||
return providers;
|
||||
@@ -1292,52 +1324,52 @@ public class AdminUiController : ControllerBase
|
||||
return "available";
|
||||
}
|
||||
|
||||
private List<AdminUiPriorityGroup> BuildPriorityGroups()
|
||||
private List<AdminUiPriorityGroup> BuildPriorityGroups(EffectiveProviderPolicySnapshot? policy)
|
||||
{
|
||||
var activeBackend = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin";
|
||||
var pinnedLocalProvider = BuildPinnedLocalProvider(activeBackend);
|
||||
return
|
||||
[
|
||||
Priority(
|
||||
ProviderCapabilityKind.Metadata,
|
||||
"metadata",
|
||||
"Metadata search order",
|
||||
"Used only for discovery (titles, artists, albums, ISRCs). Playback uses Streaming and Download order below.",
|
||||
"MULTI_PROVIDER_METADATA_ORDER",
|
||||
"MULTI_PROVIDER_ENABLED_SEARCH",
|
||||
"apple-download,deezer,qobuz",
|
||||
pinnedProvider: null),
|
||||
pinnedProvider: null,
|
||||
policy: policy),
|
||||
Priority(
|
||||
ProviderCapabilityKind.Download,
|
||||
"download",
|
||||
"Download priority",
|
||||
"Download routes after the local library. Drag to change which source fills a missing track.",
|
||||
"MULTI_PROVIDER_DOWNLOAD_ORDER",
|
||||
null,
|
||||
"apple-download,deezer,qobuz",
|
||||
pinnedProvider: pinnedLocalProvider),
|
||||
pinnedProvider: pinnedLocalProvider,
|
||||
policy: policy),
|
||||
Priority(
|
||||
ProviderCapabilityKind.Streaming,
|
||||
"streaming",
|
||||
"Streaming priority",
|
||||
"Stream routes after the local library. Drag to change which source plays a missing track.",
|
||||
"MULTI_PROVIDER_STREAMING_ORDER",
|
||||
null,
|
||||
"apple-download,deezer,qobuz",
|
||||
pinnedProvider: pinnedLocalProvider),
|
||||
pinnedProvider: pinnedLocalProvider,
|
||||
policy: policy),
|
||||
Priority(
|
||||
ProviderCapabilityKind.Playlist,
|
||||
"playlist",
|
||||
"Playlist discovery priority",
|
||||
"Order used when fetching playlists and playlist tracks from each source.",
|
||||
"MULTI_PROVIDER_PLAYLIST_ORDER",
|
||||
"MULTI_PROVIDER_ENABLED_PLAYLIST",
|
||||
"spotify,deezer,qobuz",
|
||||
pinnedProvider: null),
|
||||
pinnedProvider: null,
|
||||
policy: policy),
|
||||
Priority(
|
||||
ProviderCapabilityKind.Lyrics,
|
||||
"lyrics",
|
||||
"Lyrics priority",
|
||||
"Order used for lyrics lookup when a song is played or requested.",
|
||||
"MULTI_PROVIDER_LYRICS_ORDER",
|
||||
null,
|
||||
"spotify,apple-download,lrclib",
|
||||
pinnedProvider: pinnedLocalProvider)
|
||||
pinnedProvider: pinnedLocalProvider,
|
||||
policy: policy)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1364,20 +1396,22 @@ public class AdminUiController : ControllerBase
|
||||
}
|
||||
|
||||
private AdminUiPriorityGroup Priority(
|
||||
ProviderCapabilityKind capability,
|
||||
string id,
|
||||
string label,
|
||||
string description,
|
||||
string envKey,
|
||||
string? enabledEnvKey,
|
||||
string fallback,
|
||||
AdminUiPinnedProvider? pinnedProvider)
|
||||
AdminUiPinnedProvider? pinnedProvider,
|
||||
EffectiveProviderPolicySnapshot? policy)
|
||||
{
|
||||
var value = _configuration[envKey] ?? fallback;
|
||||
var providers = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
var definition = ProviderOrderPolicyCatalog.Find(capability)!;
|
||||
var providers = (policy?.GetProviderOrder(capability) ??
|
||||
(_configuration[definition.BootstrapKey] ?? definition.DefaultValue)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(p => p.ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (_providerRegistry != null && Enum.TryParse<ProviderCapabilityKind>(id, true, out var capability))
|
||||
if (_providerRegistry != null)
|
||||
{
|
||||
providers.AddRange(_providerRegistry.FindByCapability(capability, includeNonOperational: true)
|
||||
.Select(provider => provider.Id)
|
||||
@@ -1389,18 +1423,22 @@ public class AdminUiController : ControllerBase
|
||||
Id = id,
|
||||
Label = label,
|
||||
Description = description,
|
||||
EnvKey = envKey,
|
||||
EnvKey = definition.BootstrapKey,
|
||||
EnabledEnvKey = enabledEnvKey,
|
||||
Providers = providers,
|
||||
PinnedProvider = pinnedProvider
|
||||
};
|
||||
}
|
||||
|
||||
private string ProviderStatus(string id, string configuredStatus)
|
||||
private string ProviderStatus(
|
||||
string id,
|
||||
string configuredStatus,
|
||||
IReadOnlySet<string>? disabledProviders)
|
||||
{
|
||||
var disabled = (_configuration["MULTI_PROVIDER_DISABLED_PROVIDERS"] ?? string.Empty)
|
||||
var disabled = disabledProviders?.Contains(id) ??
|
||||
(_configuration["MULTI_PROVIDER_DISABLED_PROVIDERS"] ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Any(p => p.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||
.Contains(id, StringComparer.OrdinalIgnoreCase);
|
||||
return disabled ? "disabled" : configuredStatus;
|
||||
}
|
||||
|
||||
@@ -1421,10 +1459,10 @@ public class AdminUiController : ControllerBase
|
||||
Field("EXPLICIT_FILTER", "Explicit filter", "select", "explicitFilter", ["All", "ExplicitOnly", "CleanOnly"]),
|
||||
Field(
|
||||
"MATCHING_LOCAL_PREFERENCE_PERCENT",
|
||||
"Local match window",
|
||||
"Tentative local window",
|
||||
"number",
|
||||
"matching.localPreferencePercent",
|
||||
helpText: "A local candidate wins when it is no more than this many confidence points behind the strongest result. Default: 7%.",
|
||||
helpText: "Only used when no candidate qualifies for automatic acceptance. Accepted matches always use local first, then Streaming priority. This window never raises confidence. Default: 7 points.",
|
||||
min: 0,
|
||||
max: 20)
|
||||
]),
|
||||
|
||||
@@ -293,6 +293,65 @@ public class ConfigController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("config/effective-provider-policy")]
|
||||
public async Task<IActionResult> GetEffectiveProviderPolicy(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var adminCheck = RequireAdministratorForSensitiveOperation("effective provider policy inspection");
|
||||
if (adminCheck != null)
|
||||
{
|
||||
return adminCheck;
|
||||
}
|
||||
|
||||
var session = GetAdminSession();
|
||||
if (session?.TenantId is not { } tenantId)
|
||||
{
|
||||
return Conflict(new
|
||||
{
|
||||
error = "The administrator session is not linked to an Allstarr tenant.",
|
||||
code = "tenant_required"
|
||||
});
|
||||
}
|
||||
|
||||
var services = HttpContext.RequestServices;
|
||||
var policy = await services.GetRequiredService<IEffectiveProviderPolicyResolver>()
|
||||
.ResolveAsync(tenantId, cancellationToken);
|
||||
var contextFactory = services.GetRequiredService<IDbContextFactory<AllstarrDbContext>>();
|
||||
await using var db = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var eligibleAccounts = await db.ProviderAccounts.AsNoTracking()
|
||||
.Where(item => item.Enabled && (item.TenantId == null || item.TenantId == tenantId))
|
||||
.Select(item => new { item.ProviderId, item.Scope })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var accountScopes = eligibleAccounts
|
||||
.GroupBy(item => new { item.ProviderId, item.Scope })
|
||||
.Select(group => new
|
||||
{
|
||||
providerId = group.Key.ProviderId,
|
||||
scope = group.Key.Scope.ToString().ToLowerInvariant(),
|
||||
count = group.Count()
|
||||
})
|
||||
.OrderBy(item => item.providerId)
|
||||
.ThenBy(item => item.scope)
|
||||
.ToArray();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
audioQuality = policy.AudioQuality,
|
||||
disabledProviders = policy.DisabledProviders.Order(StringComparer.Ordinal).ToArray(),
|
||||
providerOrders = ProviderOrderPolicyCatalog.Definitions.Select(definition => new
|
||||
{
|
||||
capability = definition.Capability.ToString().ToLowerInvariant(),
|
||||
order = policy.GetProviderOrder(definition.Capability)
|
||||
}),
|
||||
accountScopes,
|
||||
routeSelection = new
|
||||
{
|
||||
localPreferencePercent = policy.LocalPreferenceWindow * 100d,
|
||||
localReason = "Prefer a playable local route when its score is within the configured local preference window.",
|
||||
externalReason = "Otherwise choose the first available provider in effective order that satisfies the requested quality."
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("config")]
|
||||
public async Task<IActionResult> UpdateConfig([FromBody] ConfigUpdateRequest request)
|
||||
{
|
||||
|
||||
@@ -79,6 +79,7 @@ public class DownloadActivityController : ControllerBase
|
||||
var position = (int)Math.Max(0, state.PositionTicks / TimeSpan.TicksPerSecond);
|
||||
deliveryState.TryGetValue(DeliveryKey(state.UserId, itemId), out var delivery);
|
||||
var streamSource = _playbackDeliveries?.StreamFor(state.TenantId, state.UserId, state.DeviceId, itemId);
|
||||
var externalIdentity = ExternalPlaybackMetadataResolver.ParseTrackIdentity(itemId);
|
||||
var threshold = duration is >= 30 ? Math.Min(duration.Value / 2d, 240d) : (double?)null;
|
||||
items.Add(new NowPlayingEntry
|
||||
{
|
||||
@@ -96,8 +97,10 @@ public class DownloadActivityController : ControllerBase
|
||||
Album = metadata?.Album,
|
||||
ProviderId = streamSource?.ProviderId ?? ResolvePlaybackProvider(itemId),
|
||||
CatalogProviderId = ResolvePlaybackProvider(itemId),
|
||||
SourceConfirmed = streamSource != null || ExternalPlaybackMetadataResolver.ParseTrackIdentity(itemId) == null,
|
||||
SourceConfirmed = streamSource != null || externalIdentity == null,
|
||||
Cached = streamSource?.Cached ?? false,
|
||||
RouteReason = streamSource?.SelectionReason ??
|
||||
(externalIdentity == null ? "native-local-library" : null),
|
||||
ProviderAccountName = streamSource?.AccountId == delivery?.Event.ProviderAccountId
|
||||
? delivery?.ProviderAccountName : null,
|
||||
ArtworkUrl = string.IsNullOrWhiteSpace(metadata?.CoverArtUrl) ? null : ArtworkUrl(itemId),
|
||||
@@ -429,6 +432,7 @@ public class DownloadActivityController : ControllerBase
|
||||
public required string CatalogProviderId { get; init; }
|
||||
public bool SourceConfirmed { get; init; }
|
||||
public bool Cached { get; init; }
|
||||
public string? RouteReason { get; init; }
|
||||
public string? ProviderAccountName { get; init; }
|
||||
public string? ArtworkUrl { get; init; }
|
||||
public int PositionSeconds { get; init; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Core.Intelligence;
|
||||
using allstarr.Core.Configuration;
|
||||
using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Playback;
|
||||
@@ -12,6 +13,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ReleaseFeature(ReleaseFeatureKind.Intelligence)]
|
||||
[Route("api/admin/intelligence")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public sealed partial class IntelligenceController(
|
||||
|
||||
@@ -151,9 +151,9 @@ public partial class JellyfinController
|
||||
{
|
||||
await _managedTrackCache.WrapAsync(
|
||||
routed,
|
||||
protocol,
|
||||
routed.ServingProviderId,
|
||||
routed.ServingExternalId ?? externalId,
|
||||
requestedQuality,
|
||||
HttpMethods.IsHead(Request.Method),
|
||||
() => _providerGateway.GetSongAsync(protocol, routed.ServingProviderId,
|
||||
routed.ServingExternalId ?? externalId),
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Options;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Scrobbling;
|
||||
@@ -1450,18 +1451,34 @@ public partial class JellyfinController : ControllerBase
|
||||
[HttpGet("System/Info/Public")]
|
||||
public async Task<IActionResult> GetPublicSystemInfo()
|
||||
{
|
||||
var (success, serverName, version) = await _proxyService.TestConnectionAsync();
|
||||
|
||||
return _responseBuilder.CreateJsonResponse(new
|
||||
var (upstream, statusCode) = await _proxyService.GetJsonAsync(
|
||||
"System/Info/Public",
|
||||
clientHeaders: Request.Headers);
|
||||
using (upstream)
|
||||
{
|
||||
LocalAddress = Request.Host.ToString(),
|
||||
ServerName = serverName ?? "Allstarr",
|
||||
Version = version ?? AppVersion.Version,
|
||||
ProductName = "Allstarr (Jellyfin Proxy)",
|
||||
OperatingSystem = Environment.OSVersion.Platform.ToString(),
|
||||
Id = _settings.DeviceId,
|
||||
StartupWizardCompleted = true
|
||||
});
|
||||
if (statusCode == StatusCodes.Status200OK && upstream != null &&
|
||||
JsonNode.Parse(upstream.RootElement.GetRawText()) is JsonObject publicInfo)
|
||||
{
|
||||
// Clients must connect back through Allstarr, but every Jellyfin-owned
|
||||
// identity and capability field remains authoritative.
|
||||
publicInfo["LocalAddress"] = $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
||||
return Content(publicInfo.ToJsonString(), "application/json");
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Jellyfin public system information returned {StatusCode}; using the configured proxy identity",
|
||||
statusCode);
|
||||
return _responseBuilder.CreateJsonResponse(new
|
||||
{
|
||||
LocalAddress = $"{Request.Scheme}://{Request.Host}{Request.PathBase}",
|
||||
ServerName = "Allstarr",
|
||||
Version = AppVersion.Version,
|
||||
ProductName = "Allstarr (Jellyfin Proxy)",
|
||||
OperatingSystem = Environment.OSVersion.Platform.ToString(),
|
||||
Id = _settings.DeviceId,
|
||||
StartupWizardCompleted = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("", Order = 99)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using allstarr.Core.Intelligence;
|
||||
using allstarr.Core.Configuration;
|
||||
using allstarr.Core.Playback;
|
||||
using allstarr.Core.Protocols;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ReleaseFeature(ReleaseFeatureKind.Intelligence)]
|
||||
[Route("apis/listenbrainz/1")]
|
||||
public sealed class ListenBrainzIntakeController(
|
||||
ListeningIntakeTokenService tokens,
|
||||
|
||||
@@ -11,6 +11,7 @@ using allstarr.Core.Protocols;
|
||||
using allstarr.Core.Routing;
|
||||
using allstarr.Core.Secrets;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Core.Settings;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Common;
|
||||
@@ -43,7 +44,8 @@ public sealed class PlaylistLinksController(
|
||||
IPlaylistVirtualizationService virtualization,
|
||||
IPlaylistTrackRetentionQueue retentionQueue,
|
||||
IConfiguration configuration,
|
||||
ApplicationCacheRequestCoalescer requestCoalescer) : ControllerBase
|
||||
ApplicationCacheRequestCoalescer requestCoalescer,
|
||||
IEffectiveProviderPolicyResolver? effectivePolicies = null) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/admin/playlist-sources")]
|
||||
public async Task<IActionResult> ListPlaylistSources(CancellationToken cancellationToken)
|
||||
@@ -82,10 +84,22 @@ public sealed class PlaylistLinksController(
|
||||
session.AllstarrUserId, "playlist", session.IsAdministrator))
|
||||
.ToArray();
|
||||
var blockedAccounts = capableAccounts.Except(availableAccounts).ToArray();
|
||||
var configuredProviderOrder = (configuration["Providers:PlaylistOrder"] ??
|
||||
configuration["MULTI_PROVIDER_PLAYLIST_ORDER"] ??
|
||||
"spotify,deezer,qobuz")
|
||||
var effectivePolicy = effectivePolicies == null || !session.TenantId.HasValue
|
||||
? null
|
||||
: await effectivePolicies.ResolveAsync(session.TenantId.Value, cancellationToken);
|
||||
var playlistOrderDefinition = ProviderOrderPolicyCatalog.Find(ProviderCapabilityKind.Playlist)!;
|
||||
var bootstrapOrder = (configuration[playlistOrderDefinition.SettingKey] ??
|
||||
configuration[playlistOrderDefinition.BootstrapKey] ??
|
||||
playlistOrderDefinition.DefaultValue)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Concat(supportedProviders.Keys.Order(StringComparer.Ordinal))
|
||||
.Where(supportedProviders.ContainsKey)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var configuredProviderOrder = (effectivePolicy?.ApplyProviderAvailability(
|
||||
ProviderCapabilityKind.Playlist,
|
||||
supportedProviders.Keys) ??
|
||||
bootstrapOrder)
|
||||
.Select((id, index) => (id: id.ToLowerInvariant(), index))
|
||||
.GroupBy(item => item.id)
|
||||
.ToDictionary(group => group.Key, group => group.First().index);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Settings;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Services.Admin;
|
||||
@@ -14,7 +15,8 @@ namespace allstarr.Controllers;
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public sealed class ProviderDiagnosticsController(
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
ProviderCtsDiagnosticRunner diagnosticRunner) : ControllerBase
|
||||
ProviderCtsDiagnosticRunner diagnosticRunner,
|
||||
IEffectiveProviderPolicyResolver providerPolicy) : ControllerBase
|
||||
{
|
||||
[HttpGet("deep-stream/latest")]
|
||||
public async Task<IActionResult> LatestDeepStream(CancellationToken cancellationToken)
|
||||
@@ -109,11 +111,17 @@ public sealed class ProviderDiagnosticsController(
|
||||
|
||||
try
|
||||
{
|
||||
var quality = request.Quality;
|
||||
if (quality == ProviderAudioQuality.Any)
|
||||
{
|
||||
var policy = await providerPolicy.ResolveAsync(session.TenantId.Value, cancellationToken);
|
||||
quality = AudioQualityPolicy.RequestedQuality(policy.AudioQuality);
|
||||
}
|
||||
var result = await diagnosticRunner.MeasureAsync(
|
||||
actor,
|
||||
providerId,
|
||||
request.ProviderAccountId,
|
||||
request.Quality,
|
||||
quality,
|
||||
correlationId,
|
||||
request.TrackId,
|
||||
cancellationToken);
|
||||
|
||||
@@ -262,9 +262,9 @@ public partial class SubsonicController : ControllerBase
|
||||
{
|
||||
await _managedTrackCache.WrapAsync(
|
||||
routed,
|
||||
CurrentProtocolContext,
|
||||
routed.ServingProviderId,
|
||||
routed.ServingExternalId ?? externalId!,
|
||||
requestedQuality,
|
||||
HttpMethods.IsHead(Request.Method),
|
||||
() => _providerGateway.GetSongAsync(CurrentProtocolContext, routed.ServingProviderId,
|
||||
routed.ServingExternalId ?? externalId!),
|
||||
|
||||
@@ -7,6 +7,7 @@ using allstarr.Core.Matching;
|
||||
using allstarr.Core.Providers.Spotify;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Core.Settings;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Services.Admin;
|
||||
@@ -32,7 +33,8 @@ public sealed class TrackMatchesController(
|
||||
IHttpClientFactory httpClients,
|
||||
IMediaAssetResolver mediaAssets,
|
||||
TrackMatchDecisionEngine matcher,
|
||||
TrackRematchAllService bulkRematches) : ControllerBase
|
||||
TrackRematchAllService bulkRematches,
|
||||
IEffectiveProviderPolicyResolver? effectivePolicies = null) : ControllerBase
|
||||
{
|
||||
public sealed record ResolveTrackMatchRequest(
|
||||
string TargetType,
|
||||
@@ -301,8 +303,12 @@ public sealed class TrackMatchesController(
|
||||
.ToArray(), StringComparer.Ordinal);
|
||||
var identities = review.ProviderIdentities
|
||||
.GroupBy(item => item.CanonicalRecordingId).ToDictionary(group => group.Key, group => group.ToArray());
|
||||
var playableProviders = providerGateway.GetProviderOrder(ProviderCapabilityKind.Streaming)
|
||||
.Concat(providerGateway.GetProviderOrder(ProviderCapabilityKind.Download))
|
||||
var streamingOrder = await ProviderOrderAsync(
|
||||
tenantId, ProviderCapabilityKind.Streaming, cancellationToken);
|
||||
var downloadOrder = await ProviderOrderAsync(
|
||||
tenantId, ProviderCapabilityKind.Download, cancellationToken);
|
||||
var playableProviders = streamingOrder
|
||||
.Concat(downloadOrder)
|
||||
.Select(ExternalTrackPlaybackPolicy.Normalize)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
@@ -415,9 +421,10 @@ public sealed class TrackMatchesController(
|
||||
limit,
|
||||
sourceCandidates,
|
||||
cancellationToken);
|
||||
var effectiveMatcher = await MatchingEngineAsync(tenantId, cancellationToken);
|
||||
var scores = source == null
|
||||
? []
|
||||
: matcher.ScoreCandidates(source, tracks.Select(ToCandidate))
|
||||
: effectiveMatcher.ScoreCandidates(source, tracks.Select(ToCandidate))
|
||||
.ToDictionary(item => item.LibraryTrackId);
|
||||
var values = tracks.Select(item => new
|
||||
{
|
||||
@@ -453,8 +460,13 @@ public sealed class TrackMatchesController(
|
||||
if (provider.Length > 128) return BadRequest(new { error = "The playback provider is invalid" });
|
||||
limit = Math.Clamp(limit, 1, 50);
|
||||
|
||||
var playableProviders = providerGateway.GetProviderOrder(ProviderCapabilityKind.Streaming)
|
||||
.Concat(providerGateway.GetProviderOrder(ProviderCapabilityKind.Download))
|
||||
var tenantId = session!.TenantId!.Value;
|
||||
var streamingOrder = await ProviderOrderAsync(
|
||||
tenantId, ProviderCapabilityKind.Streaming, cancellationToken);
|
||||
var downloadOrder = await ProviderOrderAsync(
|
||||
tenantId, ProviderCapabilityKind.Download, cancellationToken);
|
||||
var playableProviders = streamingOrder
|
||||
.Concat(downloadOrder)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (provider.Length > 0 &&
|
||||
@@ -484,18 +496,20 @@ public sealed class TrackMatchesController(
|
||||
.Take(limit)
|
||||
.ToArray();
|
||||
var source = await ReviewSourceAsync(session!, externalSnapshotId, cancellationToken);
|
||||
var effectiveMatcher = await MatchingEngineAsync(tenantId, cancellationToken);
|
||||
var candidates = songs.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
Candidate = ToCandidate(
|
||||
song,
|
||||
session!.TenantId!.Value,
|
||||
tenantId,
|
||||
session.AllstarrUserId!.Value,
|
||||
libraryScopeId ?? string.Empty)
|
||||
}).ToArray();
|
||||
var scores = source == null
|
||||
? []
|
||||
: matcher.ScoreCandidates(source, candidates.Select(item => item.Candidate))
|
||||
: effectiveMatcher.ScoreCandidates(source, candidates.Select(item => item.Candidate),
|
||||
streamingOrder)
|
||||
.ToDictionary(item => item.LibraryTrackId);
|
||||
var ranked = candidates
|
||||
.Select(item => new
|
||||
@@ -953,12 +967,9 @@ public sealed class TrackMatchesController(
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
var title = Text(root, "title") ?? Text(root, "Title") ?? Text(root, "name") ?? Text(root, "Name");
|
||||
var artist = Text(root, "artist") ?? Text(root, "Artist");
|
||||
if (artist == null && (root.TryGetProperty("artists", out var artists) || root.TryGetProperty("Artists", out artists)) && artists.ValueKind == JsonValueKind.Array)
|
||||
artist = string.Join(", ", artists.EnumerateArray().Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : Text(item, "name")).Where(item => item != null));
|
||||
return (
|
||||
title,
|
||||
artist,
|
||||
TrackSnapshotMetadata.Artist(root),
|
||||
Text(root, "album") ?? Text(root, "Album") ?? Text(root, "albumTitle") ?? Text(root, "AlbumTitle"),
|
||||
Text(root, "artworkUrl") ?? Text(root, "ArtworkUrl") ?? Text(root, "coverUrl") ?? Text(root, "CoverUrl") ??
|
||||
Text(root, "imageUrl") ?? Text(root, "ImageUrl") ??
|
||||
@@ -1194,6 +1205,21 @@ public sealed class TrackMatchesController(
|
||||
? value.GetBoolean()
|
||||
: null;
|
||||
|
||||
private async Task<IReadOnlyList<string>> ProviderOrderAsync(
|
||||
Guid tenantId,
|
||||
ProviderCapabilityKind capability,
|
||||
CancellationToken cancellationToken) => effectivePolicies == null
|
||||
? providerGateway.GetProviderOrder(capability)
|
||||
: (await effectivePolicies.ResolveAsync(tenantId, cancellationToken))
|
||||
.ApplyProviderAvailability(capability, providerGateway.GetProviderOrder(capability));
|
||||
|
||||
private async Task<TrackMatchDecisionEngine> MatchingEngineAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken) => effectivePolicies == null
|
||||
? matcher
|
||||
: matcher.WithLocalPriorityWindow(
|
||||
(await effectivePolicies.ResolveAsync(tenantId, cancellationToken)).LocalPreferenceWindow);
|
||||
|
||||
private bool TrySession(out AdminAuthSession? session, out IActionResult? error)
|
||||
{
|
||||
session = null; error = null;
|
||||
|
||||
@@ -18,6 +18,15 @@ public static class AudioQualityPolicy
|
||||
_ => throw new ArgumentException($"Audio quality step '{step}' is not supported.", nameof(step))
|
||||
};
|
||||
|
||||
public static ProviderAudioQuality RequestedQuality(string step) => step switch
|
||||
{
|
||||
"DataSaver" => ProviderAudioQuality.DataSaver,
|
||||
"High" => ProviderAudioQuality.Lossy,
|
||||
"CdLossless" => ProviderAudioQuality.Lossless,
|
||||
"HiResLossless" or DefaultStep => ProviderAudioQuality.HighResolution,
|
||||
_ => throw new ArgumentException($"Audio quality step '{step}' is not supported.", nameof(step))
|
||||
};
|
||||
|
||||
public static string FromProviderCeilings(string? apple, string? deezer, string? qobuz)
|
||||
{
|
||||
var rank = Math.Min(AppleRank(apple), Math.Min(DeezerRank(deezer), QobuzRank(qobuz)));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace allstarr.Core.Configuration;
|
||||
|
||||
public enum ReleaseFeatureKind
|
||||
{
|
||||
Intelligence
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class ReleaseFeatureAttribute(ReleaseFeatureKind feature) : Attribute
|
||||
{
|
||||
public ReleaseFeatureKind Feature { get; } = feature;
|
||||
}
|
||||
|
||||
public sealed record ReleaseComposition(string Profile)
|
||||
{
|
||||
public const string CoreProfile = "core";
|
||||
public const string DevelopmentProfile = "development";
|
||||
|
||||
public static ReleaseComposition Core { get; } = new(CoreProfile);
|
||||
public static ReleaseComposition Development { get; } = new(DevelopmentProfile);
|
||||
|
||||
public bool IntelligenceEnabled => Profile == DevelopmentProfile;
|
||||
|
||||
public bool Includes(ReleaseFeatureKind feature) => feature switch
|
||||
{
|
||||
ReleaseFeatureKind.Intelligence => IntelligenceEnabled,
|
||||
_ => false
|
||||
};
|
||||
|
||||
public bool IncludesScheduledJob(string jobType) =>
|
||||
IntelligenceEnabled ||
|
||||
!jobType.Equals("recommendation.generate", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static ReleaseComposition Resolve(IConfiguration configuration)
|
||||
{
|
||||
var profile = configuration["Release:Profile"]?.Trim().ToLowerInvariant();
|
||||
return profile switch
|
||||
{
|
||||
null or "" or CoreProfile => Core,
|
||||
DevelopmentProfile => Development,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Release:Profile must be '{CoreProfile}' or '{DevelopmentProfile}'.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -6,6 +10,11 @@ namespace allstarr.Core.Downloads;
|
||||
public sealed class DownloadedSongMappingEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ScopeKey { get; set; } = DownloadedSongMappingScope.LegacyKey;
|
||||
public Guid? TenantId { get; set; }
|
||||
public Guid? ProviderAccountId { get; set; }
|
||||
public string? LibraryScopeId { get; set; }
|
||||
public ProviderAudioQuality AudioQuality { get; set; }
|
||||
public string ProviderId { get; set; } = string.Empty;
|
||||
public string ExternalId { get; set; } = string.Empty;
|
||||
public string LocalPath { get; set; } = string.Empty;
|
||||
@@ -16,6 +25,21 @@ public sealed class DownloadedSongMappingEntity
|
||||
public long Revision { get; set; }
|
||||
}
|
||||
|
||||
public sealed record DownloadedSongMappingScope(
|
||||
Guid TenantId,
|
||||
Guid? ProviderAccountId,
|
||||
string? LibraryScopeId,
|
||||
ProviderAudioQuality AudioQuality)
|
||||
{
|
||||
public const string LegacyKey = "legacy";
|
||||
|
||||
public string Key => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\u001f',
|
||||
TenantId.ToString("N"),
|
||||
ProviderAccountId?.ToString("N") ?? string.Empty,
|
||||
LibraryScopeId?.Trim().ToLowerInvariant() ?? string.Empty,
|
||||
((int)AudioQuality).ToString(CultureInfo.InvariantCulture)))));
|
||||
}
|
||||
|
||||
public interface IDownloadedSongMappingStore
|
||||
{
|
||||
Task<DownloadedSongMappingEntity?> FindAsync(
|
||||
@@ -23,6 +47,12 @@ public interface IDownloadedSongMappingStore
|
||||
string externalId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<DownloadedSongMappingEntity?> FindAsync(
|
||||
DownloadedSongMappingScope scope,
|
||||
string providerId,
|
||||
string externalId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task UpsertAsync(
|
||||
DownloadedSongMappingEntity mapping,
|
||||
CancellationToken cancellationToken = default);
|
||||
@@ -42,6 +72,8 @@ public static class DownloadedSongMappingModelConfiguration
|
||||
entity.ToTable("downloaded_song_mappings");
|
||||
entity.HasKey(item => item.Id);
|
||||
entity.Property(item => item.Id).ValueGeneratedNever();
|
||||
entity.Property(item => item.ScopeKey).HasMaxLength(64).IsRequired();
|
||||
entity.Property(item => item.LibraryScopeId).HasMaxLength(300);
|
||||
entity.Property(item => item.ProviderId).HasMaxLength(100).IsRequired();
|
||||
entity.Property(item => item.ExternalId).HasMaxLength(500).IsRequired();
|
||||
entity.Property(item => item.LocalPath).HasMaxLength(2000).IsRequired();
|
||||
@@ -49,7 +81,7 @@ public static class DownloadedSongMappingModelConfiguration
|
||||
entity.Property(item => item.Artist).HasMaxLength(500).IsRequired();
|
||||
entity.Property(item => item.Album).HasMaxLength(500).IsRequired();
|
||||
entity.Property(item => item.Revision).IsConcurrencyToken();
|
||||
entity.HasIndex(item => new { item.ProviderId, item.ExternalId })
|
||||
entity.HasIndex(item => new { item.ScopeKey, item.ProviderId, item.ExternalId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_downloaded_song_mapping_identity");
|
||||
});
|
||||
@@ -68,17 +100,33 @@ public sealed class EfDownloadedSongMappingStore(
|
||||
return await db.DownloadedSongMappings
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
item => item.ProviderId == providerId && item.ExternalId == externalId,
|
||||
item => item.ScopeKey == DownloadedSongMappingScope.LegacyKey &&
|
||||
item.ProviderId == providerId && item.ExternalId == externalId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DownloadedSongMappingEntity?> FindAsync(
|
||||
DownloadedSongMappingScope scope,
|
||||
string providerId,
|
||||
string externalId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync(cancellationToken);
|
||||
var scopeKey = scope.Key;
|
||||
return await db.DownloadedSongMappings.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.ScopeKey == scopeKey &&
|
||||
item.ProviderId == providerId && item.ExternalId == externalId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(
|
||||
DownloadedSongMappingEntity mapping,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync(cancellationToken);
|
||||
var existing = await db.DownloadedSongMappings.SingleOrDefaultAsync(
|
||||
item => item.ProviderId == mapping.ProviderId && item.ExternalId == mapping.ExternalId,
|
||||
item => item.ScopeKey == mapping.ScopeKey &&
|
||||
item.ProviderId == mapping.ProviderId && item.ExternalId == mapping.ExternalId,
|
||||
cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
@@ -87,6 +135,10 @@ public sealed class EfDownloadedSongMappingStore(
|
||||
else
|
||||
{
|
||||
existing.LocalPath = mapping.LocalPath;
|
||||
existing.TenantId = mapping.TenantId;
|
||||
existing.ProviderAccountId = mapping.ProviderAccountId;
|
||||
existing.LibraryScopeId = mapping.LibraryScopeId;
|
||||
existing.AudioQuality = mapping.AudioQuality;
|
||||
existing.Title = mapping.Title;
|
||||
existing.Artist = mapping.Artist;
|
||||
existing.Album = mapping.Album;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Routing;
|
||||
using allstarr.Core.Settings;
|
||||
|
||||
namespace allstarr.Core.Downloads;
|
||||
|
||||
@@ -47,7 +48,8 @@ public sealed class ManagedTrackDownloadService(
|
||||
IProviderRegistry providers,
|
||||
ProviderDownloadArtifactResolver artifacts,
|
||||
IPlatformClock clock,
|
||||
IProviderRouteDecisionStore routeDecisions)
|
||||
IProviderRouteDecisionStore routeDecisions,
|
||||
IEffectiveProviderPolicyResolver? effectivePolicies = null)
|
||||
{
|
||||
public async Task<ManagedTrackDownloadResult> ExecuteAsync(
|
||||
ManagedTrackDownloadCommand command,
|
||||
@@ -75,10 +77,19 @@ public sealed class ManagedTrackDownloadService(
|
||||
command.ProviderId,
|
||||
ProviderResourceKind.Track,
|
||||
command.ExternalTrackId);
|
||||
var priority = providers.FindByCapability(ProviderCapabilityKind.Download, includeNonOperational: true)
|
||||
var availableProviders = providers.FindByCapability(
|
||||
ProviderCapabilityKind.Download,
|
||||
includeNonOperational: true)
|
||||
.Select(item => item.Id)
|
||||
.ToArray();
|
||||
if (priority.Length == 0)
|
||||
var effectivePolicy = effectivePolicies == null
|
||||
? null
|
||||
: await effectivePolicies.ResolveAsync(command.TenantId, cancellationToken);
|
||||
var priority = effectivePolicy?.ApplyProviderAvailability(
|
||||
ProviderCapabilityKind.Download,
|
||||
availableProviders)
|
||||
?? availableProviders;
|
||||
if (priority.Count == 0)
|
||||
{
|
||||
return ManagedTrackDownloadResult.Failure(
|
||||
"managed_download_provider_unavailable",
|
||||
@@ -92,6 +103,9 @@ public sealed class ManagedTrackDownloadService(
|
||||
durableJobId: command.DurableJobId,
|
||||
actingForUserId: command.OwnerUserId);
|
||||
var quality = Enum.GetValues<ProviderAudioQuality>();
|
||||
var requestedQuality = effectivePolicy == null
|
||||
? ProviderAudioQuality.Any
|
||||
: AudioQualityPolicy.RequestedQuality(effectivePolicy.AudioQuality);
|
||||
ProviderRoutePlan<IProviderDownloadCapability> plan;
|
||||
try
|
||||
{
|
||||
@@ -101,7 +115,9 @@ public sealed class ManagedTrackDownloadService(
|
||||
new ProviderExecutionPolicy(
|
||||
new ProviderQualityPolicy(
|
||||
ProviderAudioQuality.Any,
|
||||
ProviderAudioQuality.HighResolution,
|
||||
requestedQuality == ProviderAudioQuality.Any
|
||||
? ProviderAudioQuality.HighResolution
|
||||
: requestedQuality,
|
||||
allowTranscode: false),
|
||||
ProviderExplicitContentPolicy.Allow,
|
||||
allowFallback: true,
|
||||
@@ -211,7 +227,7 @@ public sealed class ManagedTrackDownloadService(
|
||||
candidateTrack,
|
||||
command.DurableJobId,
|
||||
workspace.Reference,
|
||||
ProviderAudioQuality.Any));
|
||||
requestedQuality));
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
|
||||
@@ -42,11 +42,15 @@ public sealed class ExtensionMetadataCapabilityAdapter : ExtensionCapabilityAdap
|
||||
ProviderExecutionContext context, ProviderMetadataSearchRequest request) =>
|
||||
InvokeAsync(context, "searchTracks", SearchRequest(request), value => MapPage(value, MapTrack));
|
||||
|
||||
public Task<ProviderOutcome<ProviderTrackMetadata>> GetTrackAsync(
|
||||
public async Task<ProviderOutcome<ProviderTrackMetadata>> GetTrackAsync(
|
||||
ProviderExecutionContext context, ProviderTrackLookupRequest request)
|
||||
{
|
||||
context.RequireResourceOwner(request.Id, ProviderResourceKind.Track);
|
||||
return InvokeAsync(context, "getTrack", new { id = request.Id.Value, request.ExpectedSnapshotVersion }, MapTrack);
|
||||
var outcome = await InvokeAsync(context, "getTrack",
|
||||
new { id = request.Id.Value, request.ExpectedSnapshotVersion }, MapTrack);
|
||||
return outcome.IsSuccess && outcome.RequireValue().Id != request.Id
|
||||
? Failure<ProviderTrackMetadata>(ProviderErrorKind.NotFound)
|
||||
: outcome;
|
||||
}
|
||||
|
||||
public Task<ProviderOutcome<ProviderTrackMetadata>> LookupByIsrcAsync(
|
||||
|
||||
@@ -242,6 +242,16 @@ public static class SpotiFlacExtensionCompatibility
|
||||
snapshotVersion: null
|
||||
};
|
||||
}
|
||||
var _sfTrackCache = Object.create(null);
|
||||
var _sfTrackCacheOrder = [];
|
||||
function _sfRememberTrack(track) {
|
||||
if (!track || !track.id) return track;
|
||||
var id = String(track.id);
|
||||
if (!Object.prototype.hasOwnProperty.call(_sfTrackCache, id)) _sfTrackCacheOrder.push(id);
|
||||
_sfTrackCache[id] = track;
|
||||
while (_sfTrackCacheOrder.length > 512) delete _sfTrackCache[_sfTrackCacheOrder.shift()];
|
||||
return track;
|
||||
}
|
||||
function _sfAlbum(value) {
|
||||
value = value && (value.album || value) || {};
|
||||
return { id: String(value.id || ''), title: String(value.name || value.title || ''), artists: _sfArtists(value),
|
||||
@@ -258,6 +268,7 @@ public static class SpotiFlacExtensionCompatibility
|
||||
var type = String(item.item_type || item.type || '').toLowerCase();
|
||||
return !type || type === filter || type === filter.replace(/s$/, '');
|
||||
}).map(mapper).filter(function(item) { return item.id; });
|
||||
if (filter === 'tracks') items.forEach(_sfRememberTrack);
|
||||
return { items: items, nextCursor: null, isPartial: false, snapshotVersion: null };
|
||||
}
|
||||
function _sfPlaylist(value) {
|
||||
@@ -271,9 +282,11 @@ public static class SpotiFlacExtensionCompatibility
|
||||
initialize: function(settings) { return typeof _spotiflacExtension.initialize === 'function' ? _spotiflacExtension.initialize(settings || {}) : true; },
|
||||
searchTracks: function(request) { return _sfSearch(request, 'tracks', _sfTrack); },
|
||||
getTrack: function(request) {
|
||||
if (typeof _spotiflacExtension.getTrack === 'function') return _sfTrack(_spotiflacExtension.getTrack(request.id));
|
||||
var id = String(request.id);
|
||||
if (Object.prototype.hasOwnProperty.call(_sfTrackCache, id)) return _sfTrackCache[id];
|
||||
if (typeof _spotiflacExtension.getTrack === 'function') return _sfRememberTrack(_sfTrack(_spotiflacExtension.getTrack(id)));
|
||||
var page = _sfSearch({ query: request.id, page: { limit: 10 } }, 'tracks', _sfTrack);
|
||||
return page.items.filter(function(item) { return item.id === String(request.id); })[0] || page.items[0] || { id: String(request.id), title: String(request.id), artists: [] };
|
||||
return page.items.filter(function(item) { return item.id === id; })[0] || null;
|
||||
},
|
||||
searchAlbums: function(request) { return _sfSearch(request, 'albums', _sfAlbum); },
|
||||
getAlbum: function(request) { return _sfAlbum(typeof _spotiflacExtension.getAlbum === 'function' ? _spotiflacExtension.getAlbum(request.id) : null); },
|
||||
|
||||
@@ -179,12 +179,12 @@ public sealed class MusicBrainzListeningEnrichmentJobHandler(
|
||||
facts = JsonSerializer.Serialize(match.Recording);
|
||||
if (Encoding.UTF8.GetByteCount(facts) > MusicBrainzService.MaximumResponseBytes)
|
||||
throw new MusicBrainzLookupException(
|
||||
"musicbrainz_response_too_large",
|
||||
"MusicBrainz returned more metadata than Allstarr can safely process.",
|
||||
"catalog_source_response_too_large",
|
||||
"The canonical metadata source returned more data than Allstarr can safely process.",
|
||||
false);
|
||||
}
|
||||
|
||||
occurrence.MusicBrainzSourceRevision = MusicBrainzService.SourceRevision;
|
||||
occurrence.MusicBrainzSourceRevision = match?.SourceRevision ?? MusicBrainzService.SourceRevision;
|
||||
occurrence.MusicBrainzEnrichedAt = enrichedAt;
|
||||
occurrence.MusicBrainzEnrichmentState = match == null
|
||||
? MusicBrainzEnrichmentState.Unresolved
|
||||
|
||||
@@ -6,7 +6,6 @@ public static class RecommendationSourceRegistration
|
||||
{
|
||||
public static IServiceCollection AddBuiltInRecommendationSources(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IScopedRecommendationAccountAccessor, ScopedRecommendationAccountAccessor>();
|
||||
services.AddSingleton<IJellyfinInstantMixClient, JellyfinInstantMixClient>();
|
||||
services.AddSingleton<ILocalRecommendationCatalog, LocalRecommendationCatalog>();
|
||||
services.AddHttpClient<ILastFmRecommendationClient, LastFmRecommendationClient>(client =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
using allstarr.Core.Intelligence;
|
||||
using allstarr.Core.Configuration;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Storage;
|
||||
using Cronos;
|
||||
@@ -35,15 +36,26 @@ public sealed class DurableScheduleEngine
|
||||
private readonly IDbContextFactory<AllstarrDbContext> _contextFactory;
|
||||
private readonly DurableJobQueue _queue;
|
||||
private readonly IPlatformClock _clock;
|
||||
private readonly ReleaseComposition _releaseComposition;
|
||||
|
||||
public DurableScheduleEngine(
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
DurableJobQueue queue,
|
||||
IPlatformClock clock,
|
||||
ReleaseComposition releaseComposition)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_queue = queue;
|
||||
_clock = clock;
|
||||
_releaseComposition = releaseComposition;
|
||||
}
|
||||
|
||||
public DurableScheduleEngine(
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
DurableJobQueue queue,
|
||||
IPlatformClock clock)
|
||||
: this(contextFactory, queue, clock, ReleaseComposition.Development)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_queue = queue;
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
public static DateTimeOffset? GetNextOccurrence(string expression, string timeZoneId, DateTimeOffset after)
|
||||
@@ -63,9 +75,14 @@ public sealed class DurableScheduleEngine
|
||||
{
|
||||
var now = _clock.UtcNow;
|
||||
await using var scan = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var dueIds = await scan.JobSchedules.AsNoTracking()
|
||||
var dueSchedules = scan.JobSchedules.AsNoTracking()
|
||||
.Where(item => item.Enabled && item.NextRunAt != null && item.NextRunAt <= now)
|
||||
.OrderBy(item => item.NextRunAt).ThenBy(item => item.Id)
|
||||
.AsQueryable();
|
||||
if (!_releaseComposition.IncludesScheduledJob(RecommendationJobType))
|
||||
{
|
||||
dueSchedules = dueSchedules.Where(item => item.JobType != RecommendationJobType);
|
||||
}
|
||||
var dueIds = await dueSchedules.OrderBy(item => item.NextRunAt).ThenBy(item => item.Id)
|
||||
.Select(item => item.Id).ToListAsync(cancellationToken);
|
||||
|
||||
var claimed = 0;
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace allstarr.Core.Matching;
|
||||
|
||||
public sealed record CanonicalCatalogEntityReference(
|
||||
CanonicalCatalogEntityKind Kind,
|
||||
Guid Id);
|
||||
|
||||
public sealed record CanonicalCatalogAliasInput(
|
||||
string Namespace,
|
||||
string ExternalId);
|
||||
|
||||
public sealed record CanonicalCatalogFactInput(
|
||||
string FieldName,
|
||||
string ValueJson);
|
||||
|
||||
public sealed record CanonicalCatalogSourceStamp(
|
||||
string SourceId,
|
||||
string? SourceRevision,
|
||||
string PayloadSha256,
|
||||
double Confidence,
|
||||
DateTimeOffset ObservedAt,
|
||||
DateTimeOffset? RefreshAfter);
|
||||
|
||||
public sealed record CanonicalCatalogEvidenceResult(
|
||||
int AliasesCreated,
|
||||
int AliasesSeen,
|
||||
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 static string NativeTrackNamespace(string protocol, string backendInstanceId)
|
||||
{
|
||||
protocol = ProviderContractValidation.Catalog(protocol, nameof(protocol));
|
||||
backendInstanceId = ProviderContractValidation.RequiredText(
|
||||
backendInstanceId,
|
||||
nameof(backendInstanceId),
|
||||
200);
|
||||
return $"native:{Hash($"{protocol}\n{backendInstanceId}")}";
|
||||
}
|
||||
}
|
||||
|
||||
internal static class CanonicalCatalogIdentityProjection
|
||||
{
|
||||
public static async Task ProjectRecordingSignalsAsync(
|
||||
AllstarrDbContext db,
|
||||
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;
|
||||
}
|
||||
|
||||
await CanonicalCatalogEvidenceStore.RecordInContextAsync(
|
||||
db,
|
||||
actor,
|
||||
new CanonicalCatalogEntityReference(CanonicalCatalogEntityKind.Recording, recording.Id),
|
||||
new CanonicalCatalogSourceStamp(
|
||||
"canonical-signal",
|
||||
null,
|
||||
CanonicalCatalogKeys.Hash(string.Join(
|
||||
'\n', aliases.Select(alias => $"{alias.Namespace}:{alias.ExternalId}"))),
|
||||
1,
|
||||
observedAt,
|
||||
null),
|
||||
aliases,
|
||||
[],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public static Task ProjectProviderIdentityAsync(
|
||||
AllstarrDbContext db,
|
||||
ProviderActorContext actor,
|
||||
ProviderTrackIdentityRecord identity,
|
||||
DateTimeOffset observedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var aliasNamespace = CanonicalCatalogKeys.ProviderTrackNamespace(
|
||||
identity.ProviderId,
|
||||
identity.ResourceKind,
|
||||
identity.CatalogNamespace,
|
||||
identity.Scope,
|
||||
identity.ProviderAccountId);
|
||||
return CanonicalCatalogEvidenceStore.RecordInContextAsync(
|
||||
db,
|
||||
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);
|
||||
}
|
||||
|
||||
public static Task ProjectLibraryTrackAsync(
|
||||
AllstarrDbContext db,
|
||||
ProviderActorContext actor,
|
||||
LibraryTrackRecord track,
|
||||
DateTimeOffset observedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!track.CanonicalRecordingId.HasValue)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var aliasNamespace = CanonicalCatalogKeys.NativeTrackNamespace(
|
||||
track.Protocol,
|
||||
track.BackendInstanceId);
|
||||
return CanonicalCatalogEvidenceStore.RecordInContextAsync(
|
||||
db,
|
||||
actor,
|
||||
new CanonicalCatalogEntityReference(
|
||||
CanonicalCatalogEntityKind.Recording,
|
||||
track.CanonicalRecordingId.Value),
|
||||
new CanonicalCatalogSourceStamp(
|
||||
"native-library-identity",
|
||||
$"{track.SourceModifiedAt.UtcTicks}:{track.AcceptedDecisionVersion}",
|
||||
CanonicalCatalogKeys.Hash($"{aliasNamespace}\n{track.BackendItemId}"),
|
||||
1,
|
||||
observedAt,
|
||||
null),
|
||||
[new CanonicalCatalogAliasInput(aliasNamespace, track.BackendItemId)],
|
||||
[],
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public interface ICanonicalCatalogEvidenceStore
|
||||
{
|
||||
Task<CanonicalCatalogEvidenceResult> RecordAsync(
|
||||
ProviderActorContext actor,
|
||||
CanonicalCatalogEntityReference target,
|
||||
CanonicalCatalogSourceStamp source,
|
||||
IReadOnlyCollection<CanonicalCatalogAliasInput> aliases,
|
||||
IReadOnlyCollection<CanonicalCatalogFactInput> facts,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class CanonicalCatalogEvidenceStore(
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
DurableStorageState storageState) : ICanonicalCatalogEvidenceStore
|
||||
{
|
||||
public async Task<CanonicalCatalogEvidenceResult> RecordAsync(
|
||||
ProviderActorContext actor,
|
||||
CanonicalCatalogEntityReference target,
|
||||
CanonicalCatalogSourceStamp source,
|
||||
IReadOnlyCollection<CanonicalCatalogAliasInput> aliases,
|
||||
IReadOnlyCollection<CanonicalCatalogFactInput> facts,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actor);
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(aliases);
|
||||
ArgumentNullException.ThrowIfNull(facts);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureStorageReady();
|
||||
await using var db = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var result = await RecordInContextAsync(
|
||||
db, actor, target, source, aliases, facts, cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static async Task<CanonicalCatalogEvidenceResult> RecordInContextAsync(
|
||||
AllstarrDbContext db,
|
||||
ProviderActorContext actor,
|
||||
CanonicalCatalogEntityReference target,
|
||||
CanonicalCatalogSourceStamp source,
|
||||
IReadOnlyCollection<CanonicalCatalogAliasInput> aliases,
|
||||
IReadOnlyCollection<CanonicalCatalogFactInput> facts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(db);
|
||||
ArgumentNullException.ThrowIfNull(actor);
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(aliases);
|
||||
ArgumentNullException.ThrowIfNull(facts);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
source = NormalizeSource(source);
|
||||
await ValidateActorAndTargetAsync(db, actor, target, cancellationToken);
|
||||
var createdAliases = 0;
|
||||
var seenAliases = 0;
|
||||
foreach (var alias in NormalizeAliases(aliases))
|
||||
{
|
||||
var hash = CanonicalCatalogKeys.Hash(alias.ExternalId);
|
||||
var existing = db.CanonicalCatalogAliases.Local.SingleOrDefault(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Namespace == alias.Namespace &&
|
||||
item.EntityKind == target.Kind &&
|
||||
item.ExternalIdHash == hash) ??
|
||||
await db.CanonicalCatalogAliases.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Namespace == alias.Namespace &&
|
||||
item.EntityKind == target.Kind &&
|
||||
item.ExternalIdHash == hash,
|
||||
cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
if (!existing.ExternalId.Equals(alias.ExternalId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("A catalog alias hash collision was detected.");
|
||||
}
|
||||
|
||||
if (existing.CanonicalEntityId != target.Id)
|
||||
{
|
||||
throw new InvalidOperationException("The catalog alias already identifies another canonical entity.");
|
||||
}
|
||||
|
||||
existing.LastSeenAt = source.ObservedAt;
|
||||
seenAliases++;
|
||||
continue;
|
||||
}
|
||||
|
||||
db.CanonicalCatalogAliases.Add(new CanonicalCatalogAliasRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
EntityKind = target.Kind,
|
||||
CanonicalEntityId = target.Id,
|
||||
Namespace = alias.Namespace,
|
||||
ExternalId = alias.ExternalId,
|
||||
ExternalIdHash = hash,
|
||||
CreatedAt = source.ObservedAt,
|
||||
LastSeenAt = source.ObservedAt
|
||||
});
|
||||
createdAliases++;
|
||||
}
|
||||
|
||||
var createdFacts = 0;
|
||||
var supersededFacts = 0;
|
||||
foreach (var fact in NormalizeFacts(facts))
|
||||
{
|
||||
var persisted = await db.CatalogFacts.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.EntityKind == target.Kind &&
|
||||
item.CanonicalEntityId == target.Id &&
|
||||
item.FieldName == fact.FieldName &&
|
||||
item.SourceId == source.SourceId &&
|
||||
item.SupersededAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
var active = db.CatalogFacts.Local.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.EntityKind == target.Kind &&
|
||||
item.CanonicalEntityId == target.Id &&
|
||||
item.FieldName == fact.FieldName &&
|
||||
item.SourceId == source.SourceId &&
|
||||
item.SupersededAt == null)
|
||||
.Concat(persisted)
|
||||
.DistinctBy(item => item.Id)
|
||||
.ToList();
|
||||
if (active.Any(item =>
|
||||
item.PayloadSha256.Equals(source.PayloadSha256, StringComparison.Ordinal) &&
|
||||
JsonEquivalent(item.ValueJson, fact.ValueJson)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var previous in active)
|
||||
{
|
||||
previous.SupersededAt = source.ObservedAt;
|
||||
supersededFacts++;
|
||||
}
|
||||
|
||||
db.CatalogFacts.Add(new CatalogFactRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
EntityKind = target.Kind,
|
||||
CanonicalEntityId = target.Id,
|
||||
FieldName = fact.FieldName,
|
||||
ValueJson = fact.ValueJson,
|
||||
SourceId = source.SourceId,
|
||||
SourceRevision = source.SourceRevision,
|
||||
PayloadSha256 = source.PayloadSha256,
|
||||
Confidence = source.Confidence,
|
||||
ObservedAt = source.ObservedAt,
|
||||
RefreshAfter = source.RefreshAfter
|
||||
});
|
||||
createdFacts++;
|
||||
}
|
||||
|
||||
db.AuditEvents.Add(new AuditEventRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
ActorUserId = actor.UserId,
|
||||
Category = "canonical-catalog",
|
||||
Action = "evidence.record",
|
||||
Outcome = createdAliases + createdFacts + supersededFacts == 0 ? "unchanged" : "updated",
|
||||
CorrelationId = $"catalog:{source.SourceId}:{source.PayloadSha256[..12]}",
|
||||
DetailsJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
entityKind = target.Kind.ToString(),
|
||||
canonicalEntityId = target.Id,
|
||||
source.SourceId,
|
||||
aliasesCreated = createdAliases,
|
||||
aliasesSeen = seenAliases,
|
||||
factsCreated = createdFacts,
|
||||
factsSuperseded = supersededFacts
|
||||
}),
|
||||
CreatedAt = source.ObservedAt
|
||||
});
|
||||
return new CanonicalCatalogEvidenceResult(
|
||||
createdAliases,
|
||||
seenAliases,
|
||||
createdFacts,
|
||||
supersededFacts);
|
||||
}
|
||||
|
||||
private static IEnumerable<CanonicalCatalogAliasInput> NormalizeAliases(
|
||||
IEnumerable<CanonicalCatalogAliasInput> aliases) => aliases
|
||||
.Select(alias => new CanonicalCatalogAliasInput(
|
||||
Required(alias.Namespace, nameof(alias.Namespace), 100).ToLowerInvariant(),
|
||||
Required(alias.ExternalId, nameof(alias.ExternalId), 500)))
|
||||
.Distinct();
|
||||
|
||||
private static IEnumerable<CanonicalCatalogFactInput> NormalizeFacts(
|
||||
IEnumerable<CanonicalCatalogFactInput> facts)
|
||||
{
|
||||
var normalized = facts.Select(fact => new CanonicalCatalogFactInput(
|
||||
Required(fact.FieldName, nameof(fact.FieldName), 100),
|
||||
NormalizeJson(fact.ValueJson)))
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (normalized.GroupBy(fact => fact.FieldName, StringComparer.Ordinal)
|
||||
.Any(group => group.Count() > 1))
|
||||
{
|
||||
throw new ArgumentException("One source payload cannot assert conflicting values for the same field.", nameof(facts));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static async Task ValidateActorAndTargetAsync(
|
||||
AllstarrDbContext db,
|
||||
ProviderActorContext actor,
|
||||
CanonicalCatalogEntityReference target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (target.Id == Guid.Empty || !Enum.IsDefined(target.Kind))
|
||||
{
|
||||
throw new ArgumentException("A valid canonical catalog target is required.", nameof(target));
|
||||
}
|
||||
|
||||
var actorExists = actor.UserId == null
|
||||
? db.Tenants.Local.Any(item => item.Id == actor.TenantId) ||
|
||||
await db.Tenants.AnyAsync(item => item.Id == actor.TenantId, cancellationToken)
|
||||
: db.Users.Local.Any(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == actor.UserId &&
|
||||
item.Status == PlatformUserStatus.Active) ||
|
||||
await db.Users.AnyAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == actor.UserId &&
|
||||
item.Status == PlatformUserStatus.Active,
|
||||
cancellationToken);
|
||||
if (!actorExists)
|
||||
{
|
||||
throw new UnauthorizedAccessException("The catalog actor is not active in the requested tenant.");
|
||||
}
|
||||
|
||||
var trackedTargetExists = target.Kind switch
|
||||
{
|
||||
CanonicalCatalogEntityKind.Artist => db.CanonicalArtists.Local.Any(
|
||||
item => item.TenantId == actor.TenantId && item.Id == target.Id),
|
||||
CanonicalCatalogEntityKind.ReleaseGroup => db.CanonicalReleaseGroups.Local.Any(
|
||||
item => item.TenantId == actor.TenantId && item.Id == target.Id),
|
||||
CanonicalCatalogEntityKind.Release => db.CanonicalReleases.Local.Any(
|
||||
item => item.TenantId == actor.TenantId && item.Id == target.Id),
|
||||
CanonicalCatalogEntityKind.ReleaseTrack => db.CanonicalReleaseTracks.Local.Any(
|
||||
item => item.TenantId == actor.TenantId && item.Id == target.Id),
|
||||
CanonicalCatalogEntityKind.Recording => db.CanonicalRecordings.Local.Any(
|
||||
item => item.TenantId == actor.TenantId && item.Id == target.Id),
|
||||
_ => false
|
||||
};
|
||||
var targetExists = trackedTargetExists || (target.Kind switch
|
||||
{
|
||||
CanonicalCatalogEntityKind.Artist => await db.CanonicalArtists.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == target.Id,
|
||||
cancellationToken),
|
||||
CanonicalCatalogEntityKind.ReleaseGroup => await db.CanonicalReleaseGroups.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == target.Id,
|
||||
cancellationToken),
|
||||
CanonicalCatalogEntityKind.Release => await db.CanonicalReleases.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == target.Id,
|
||||
cancellationToken),
|
||||
CanonicalCatalogEntityKind.ReleaseTrack => await db.CanonicalReleaseTracks.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == target.Id,
|
||||
cancellationToken),
|
||||
CanonicalCatalogEntityKind.Recording => await db.CanonicalRecordings.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == target.Id,
|
||||
cancellationToken),
|
||||
_ => false
|
||||
});
|
||||
if (!targetExists)
|
||||
{
|
||||
throw new KeyNotFoundException("The canonical catalog target does not exist in the actor tenant.");
|
||||
}
|
||||
}
|
||||
|
||||
private static CanonicalCatalogSourceStamp NormalizeSource(CanonicalCatalogSourceStamp source)
|
||||
{
|
||||
var sourceId = Required(source.SourceId, nameof(source.SourceId), 100).ToLowerInvariant();
|
||||
if (source.SourceRevision is { Length: > 500 })
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(source), "Source revisions are limited to 500 characters.");
|
||||
}
|
||||
|
||||
if (source.PayloadSha256.Length != 64 || source.PayloadSha256.Any(character => !Uri.IsHexDigit(character)))
|
||||
{
|
||||
throw new ArgumentException("The source payload requires a SHA-256 hash.", nameof(source));
|
||||
}
|
||||
|
||||
if (source.Confidence is < 0 or > 1 || double.IsNaN(source.Confidence))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(source), "Catalog confidence must be between zero and one.");
|
||||
}
|
||||
|
||||
if (source.RefreshAfter < source.ObservedAt)
|
||||
{
|
||||
throw new ArgumentException("Catalog refresh cannot precede observation.", nameof(source));
|
||||
}
|
||||
|
||||
return source with
|
||||
{
|
||||
SourceId = sourceId,
|
||||
SourceRevision = source.SourceRevision?.Trim(),
|
||||
PayloadSha256 = source.PayloadSha256.ToLowerInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeJson(string value)
|
||||
{
|
||||
using var document = JsonDocument.Parse(Required(value, nameof(value), 1_048_576));
|
||||
return JsonSerializer.Serialize(document.RootElement);
|
||||
}
|
||||
|
||||
private static bool JsonEquivalent(string first, string second)
|
||||
{
|
||||
using var firstDocument = JsonDocument.Parse(first);
|
||||
using var secondDocument = JsonDocument.Parse(second);
|
||||
return JsonElement.DeepEquals(firstDocument.RootElement, secondDocument.RootElement);
|
||||
}
|
||||
|
||||
private static string Required(string value, string name, int maxLength)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException("A value is required.", name)
|
||||
: value.Trim();
|
||||
return normalized.Length <= maxLength
|
||||
? normalized
|
||||
: throw new ArgumentOutOfRangeException(name, $"Values are limited to {maxLength} characters.");
|
||||
}
|
||||
|
||||
private void EnsureStorageReady()
|
||||
{
|
||||
if (storageState.GetSnapshot().Readiness != DurableStorageReadiness.Ready)
|
||||
{
|
||||
throw new InvalidOperationException("Durable storage is not ready.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,13 @@ public sealed class LibraryIndexService : ILibraryIndexService
|
||||
db.LibraryTracks.Add(record);
|
||||
}
|
||||
|
||||
await CanonicalCatalogIdentityProjection.ProjectLibraryTrackAsync(
|
||||
db,
|
||||
executionContext.RequireActor(),
|
||||
record,
|
||||
now,
|
||||
cancellationToken);
|
||||
|
||||
db.AuditEvents.Add(new AuditEventRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Services.MusicBrainz;
|
||||
|
||||
namespace allstarr.Core.Matching;
|
||||
|
||||
public interface IMusicBrainzCatalogRefreshQueue
|
||||
{
|
||||
Task<DurableJobEnqueueResult> EnqueueRecordingAsync(
|
||||
ProviderActorContext actor,
|
||||
string recordingMbid,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<DurableJobEnqueueResult> EnqueueReleaseAsync(
|
||||
ProviderActorContext actor,
|
||||
string releaseMbid,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class MusicBrainzCatalogRefreshQueue(
|
||||
DurableJobQueue jobs,
|
||||
IMusicBrainzCatalogClient client,
|
||||
IPlatformClock clock) : IMusicBrainzCatalogRefreshQueue
|
||||
{
|
||||
public Task<DurableJobEnqueueResult> EnqueueRecordingAsync(
|
||||
ProviderActorContext actor,
|
||||
string recordingMbid,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
EnqueueAsync(
|
||||
MusicBrainzCatalogDiscoveryJobHandler.Type,
|
||||
Mbid(recordingMbid),
|
||||
actor,
|
||||
correlationId,
|
||||
id => new MusicBrainzCatalogDiscoveryJobPayload(id),
|
||||
cancellationToken);
|
||||
|
||||
public Task<DurableJobEnqueueResult> EnqueueReleaseAsync(
|
||||
ProviderActorContext actor,
|
||||
string releaseMbid,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
EnqueueAsync(
|
||||
MusicBrainzCatalogRefreshJobHandler.Type,
|
||||
Mbid(releaseMbid),
|
||||
actor,
|
||||
correlationId,
|
||||
id => new MusicBrainzCatalogRefreshJobPayload(id),
|
||||
cancellationToken);
|
||||
|
||||
private Task<DurableJobEnqueueResult> EnqueueAsync<TPayload>(
|
||||
string jobType,
|
||||
string mbid,
|
||||
ProviderActorContext actor,
|
||||
string correlationId,
|
||||
Func<string, TPayload> payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actor);
|
||||
var ownerUserId = actor.EffectiveUserId ??
|
||||
throw new UnauthorizedAccessException("Catalog refresh requires a scoped user.");
|
||||
var normalizedCorrelation = string.IsNullOrWhiteSpace(correlationId)
|
||||
? throw new ArgumentException("A correlation ID is required.", nameof(correlationId))
|
||||
: correlationId.Trim();
|
||||
var generation = clock.UtcNow.UtcTicks / MusicBrainzCatalogRefreshService.RefreshInterval.Ticks;
|
||||
var identity = $"{jobType}|{actor.TenantId:N}|{ownerUserId:N}|{mbid}|{client.ConfiguredSourceRevision}|{generation}";
|
||||
var idempotencyKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant();
|
||||
return jobs.EnqueueAsync(new DurableJobEnqueueRequest<TPayload>(
|
||||
jobType,
|
||||
idempotencyKey,
|
||||
payload(mbid),
|
||||
actor.TenantId,
|
||||
ownerUserId,
|
||||
MaxAttempts: 8,
|
||||
Capability: "metadata",
|
||||
CorrelationId: normalizedCorrelation), cancellationToken);
|
||||
}
|
||||
|
||||
private static string Mbid(string? value) =>
|
||||
Guid.TryParse(value, out var parsed) && parsed != Guid.Empty
|
||||
? parsed.ToString("D")
|
||||
: throw new ArgumentException("A valid MusicBrainz ID is required.", nameof(value));
|
||||
}
|
||||
|
||||
public sealed record MusicBrainzCatalogDiscoveryJobPayload(string RecordingMbid);
|
||||
|
||||
public sealed class MusicBrainzCatalogDiscoveryJobHandler(
|
||||
IMusicBrainzCatalogClient client,
|
||||
IMusicBrainzCatalogRefreshQueue queue) : IDurableJobHandler
|
||||
{
|
||||
public const string Type = "catalog.musicbrainz.discover-recording";
|
||||
public const int MaximumReleasesPerRecording = 50;
|
||||
public string JobType => Type;
|
||||
|
||||
public async Task<DurableJobCompletion> ExecuteAsync(
|
||||
DurableJobExecutionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MusicBrainzCatalogDiscoveryJobPayload? payload;
|
||||
try { payload = context.Claim.Payload.Deserialize<MusicBrainzCatalogDiscoveryJobPayload>(); }
|
||||
catch (JsonException) { payload = null; }
|
||||
if (payload == null ||
|
||||
!Guid.TryParse(payload.RecordingMbid, out var recordingId) ||
|
||||
recordingId == Guid.Empty ||
|
||||
!context.Claim.TenantId.HasValue ||
|
||||
!context.Claim.OwnerUserId.HasValue)
|
||||
return DurableJobCompletion.Failure(
|
||||
"catalog_discovery_payload_invalid",
|
||||
"The canonical catalog discovery payload is invalid.");
|
||||
|
||||
try
|
||||
{
|
||||
var recordingMbid = recordingId.ToString("D");
|
||||
var recording = await client.LookupByMbidAsync(recordingMbid, cancellationToken);
|
||||
if (recording == null)
|
||||
return DurableJobCompletion.Failure(
|
||||
"catalog_recording_missing",
|
||||
"The canonical catalog recording no longer exists upstream.");
|
||||
var rawReleaseIds = (recording.Releases ?? []).Select(release => release.Id).ToArray();
|
||||
if (rawReleaseIds.Any(id => !Guid.TryParse(id, out var parsed) || parsed == Guid.Empty))
|
||||
return DurableJobCompletion.Failure(
|
||||
"catalog_recording_hierarchy_invalid",
|
||||
"The canonical recording contains an invalid release identity.");
|
||||
var releaseIds = rawReleaseIds
|
||||
.Select(id => Guid.Parse(id!).ToString("D"))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (releaseIds.Length == 0 || releaseIds.Length > MaximumReleasesPerRecording)
|
||||
return DurableJobCompletion.Failure(
|
||||
"catalog_recording_hierarchy_invalid",
|
||||
"The canonical recording has an invalid release fan-out.");
|
||||
|
||||
var actor = new ProviderActorContext(
|
||||
context.Claim.TenantId.Value,
|
||||
ProviderActorKind.SystemJob,
|
||||
null,
|
||||
durableJobId: context.Claim.JobId,
|
||||
actingForUserId: context.Claim.OwnerUserId.Value);
|
||||
var completed = 0;
|
||||
foreach (var releaseId in releaseIds)
|
||||
{
|
||||
await queue.EnqueueReleaseAsync(
|
||||
actor, releaseId, context.Claim.CorrelationId, cancellationToken);
|
||||
completed++;
|
||||
await context.ReportProgressAsync(new(
|
||||
"catalog.discovery",
|
||||
$"Queued {completed} of {releaseIds.Length} canonical editions.",
|
||||
completed,
|
||||
releaseIds.Length), cancellationToken);
|
||||
}
|
||||
return DurableJobCompletion.Success();
|
||||
}
|
||||
catch (MusicBrainzLookupException error) when (error.Retryable)
|
||||
{
|
||||
return DurableJobCompletion.Retry(error.Code, error.Message, error.RetryAfter);
|
||||
}
|
||||
catch (MusicBrainzLookupException error)
|
||||
{
|
||||
return DurableJobCompletion.Failure(error.Code, error.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
using System.Data;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Services.MusicBrainz;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace allstarr.Core.Matching;
|
||||
|
||||
public sealed record MusicBrainzCatalogGraph(
|
||||
MusicBrainzRelease Release,
|
||||
MusicBrainzReleaseGroup ReleaseGroup,
|
||||
IReadOnlyCollection<MusicBrainzArtist> Artists);
|
||||
|
||||
public sealed record MusicBrainzCatalogSource(
|
||||
string SourceId,
|
||||
string SourceRevision,
|
||||
DateTimeOffset ObservedAt,
|
||||
DateTimeOffset RefreshAfter);
|
||||
|
||||
public sealed record MusicBrainzCatalogIngestResult(
|
||||
Guid ReleaseGroupId,
|
||||
Guid ReleaseId,
|
||||
int ArtistCount,
|
||||
int RecordingCount,
|
||||
int ReleaseTrackCount,
|
||||
int EntitiesCreated,
|
||||
CanonicalCatalogEvidenceResult Evidence);
|
||||
|
||||
public interface IMusicBrainzCatalogIngestService
|
||||
{
|
||||
Task<MusicBrainzCatalogIngestResult> IngestAsync(
|
||||
ProviderActorContext actor,
|
||||
MusicBrainzCatalogGraph graph,
|
||||
MusicBrainzCatalogSource source,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class MusicBrainzCatalogIngestService(
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
DurableStorageState storageState) : IMusicBrainzCatalogIngestService
|
||||
{
|
||||
public async Task<MusicBrainzCatalogIngestResult> IngestAsync(
|
||||
ProviderActorContext actor,
|
||||
MusicBrainzCatalogGraph graph,
|
||||
MusicBrainzCatalogSource source,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actor);
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureStorageReady();
|
||||
var ownerUserId = actor.EffectiveUserId ??
|
||||
throw new UnauthorizedAccessException("Catalog ingestion requires a scoped user.");
|
||||
var releaseMbid = Mbid(graph.Release.Id, "release");
|
||||
var releaseGroupMbid = Mbid(graph.ReleaseGroup.Id, "release group");
|
||||
if (!string.Equals(graph.Release.ReleaseGroup?.Id, releaseGroupMbid, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("The release does not belong to the supplied release group.", nameof(graph));
|
||||
ValidateSource(source);
|
||||
ValidateTracks(graph.Release.Media);
|
||||
|
||||
await using var db = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable, cancellationToken);
|
||||
if (!await db.Users.AnyAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == ownerUserId &&
|
||||
item.Status == PlatformUserStatus.Active,
|
||||
cancellationToken))
|
||||
throw new UnauthorizedAccessException("The catalog owner is not active in the requested tenant.");
|
||||
|
||||
var created = 0;
|
||||
var artistsByMbid = new Dictionary<string, CanonicalArtistRecord>(StringComparer.OrdinalIgnoreCase);
|
||||
var suppliedArtists = graph.Artists
|
||||
.Where(item => Guid.TryParse(item.Id, out _))
|
||||
.GroupBy(item => item.Id!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
async Task<CanonicalArtistRecord> ResolveArtistAsync(MusicBrainzArtistCredit credit)
|
||||
{
|
||||
var embedded = credit.Artist ?? throw new ArgumentException("Artist credit is missing its artist identity.", nameof(graph));
|
||||
var mbid = Mbid(embedded.Id, "artist");
|
||||
if (artistsByMbid.TryGetValue(mbid, out var cached)) return cached;
|
||||
var details = suppliedArtists.GetValueOrDefault(mbid) ?? embedded;
|
||||
var existing = await db.CanonicalArtists.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId && item.MusicBrainzArtistId == mbid, cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
existing = new CanonicalArtistRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
MusicBrainzArtistId = mbid,
|
||||
CreatedAt = source.ObservedAt,
|
||||
Revision = 1
|
||||
};
|
||||
db.CanonicalArtists.Add(existing);
|
||||
created++;
|
||||
}
|
||||
ApplyArtist(existing, details, source.ObservedAt);
|
||||
artistsByMbid.Add(mbid, existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var releaseCredits = Credits(
|
||||
graph.ReleaseGroup.ArtistCredit,
|
||||
graph.Release.ArtistCredit,
|
||||
Tracks(graph.Release.Media).SelectMany(track =>
|
||||
Credits(track.ArtistCredit, track.Recording?.ArtistCredit)));
|
||||
foreach (var credit in releaseCredits) await ResolveArtistAsync(credit);
|
||||
foreach (var track in Tracks(graph.Release.Media))
|
||||
foreach (var credit in Credits(track.ArtistCredit, track.Recording?.ArtistCredit, releaseCredits))
|
||||
await ResolveArtistAsync(credit);
|
||||
|
||||
var releaseGroup = await db.CanonicalReleaseGroups.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId && item.MusicBrainzReleaseGroupId == releaseGroupMbid,
|
||||
cancellationToken);
|
||||
if (releaseGroup == null)
|
||||
{
|
||||
releaseGroup = new CanonicalReleaseGroupRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
MusicBrainzReleaseGroupId = releaseGroupMbid,
|
||||
CreatedAt = source.ObservedAt,
|
||||
Revision = 1
|
||||
};
|
||||
db.CanonicalReleaseGroups.Add(releaseGroup);
|
||||
created++;
|
||||
}
|
||||
ApplyReleaseGroup(releaseGroup, graph.ReleaseGroup, source.ObservedAt);
|
||||
|
||||
var release = await db.CanonicalReleases.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId && item.MusicBrainzReleaseId == releaseMbid,
|
||||
cancellationToken);
|
||||
if (release == null)
|
||||
{
|
||||
release = new CanonicalReleaseRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
MusicBrainzReleaseId = releaseMbid,
|
||||
CreatedAt = source.ObservedAt,
|
||||
Revision = 1
|
||||
};
|
||||
db.CanonicalReleases.Add(release);
|
||||
created++;
|
||||
}
|
||||
ApplyRelease(release, releaseGroup.Id, graph.Release, source.ObservedAt);
|
||||
|
||||
var recordings = new Dictionary<string, CanonicalRecordingRecord>(StringComparer.OrdinalIgnoreCase);
|
||||
var releaseTracks = new List<(CanonicalReleaseTrackRecord Entity, MusicBrainzReleaseTrack Source)>();
|
||||
foreach (var (medium, track) in TracksWithMedia(graph.Release.Media))
|
||||
{
|
||||
var recordingSource = track.Recording!;
|
||||
var recordingMbid = Mbid(recordingSource.Id, "recording");
|
||||
if (!recordings.TryGetValue(recordingMbid, out var recording))
|
||||
{
|
||||
recording = await db.CanonicalRecordings.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId && item.MusicBrainzRecordingId == recordingMbid,
|
||||
cancellationToken);
|
||||
if (recording == null)
|
||||
{
|
||||
recording = new CanonicalRecordingRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedByUserId = ownerUserId,
|
||||
MusicBrainzRecordingId = recordingMbid,
|
||||
CreatedAt = source.ObservedAt,
|
||||
Revision = 1
|
||||
};
|
||||
db.CanonicalRecordings.Add(recording);
|
||||
created++;
|
||||
}
|
||||
ApplyRecording(recording, recordingSource, track, source.ObservedAt);
|
||||
recordings.Add(recordingMbid, recording);
|
||||
}
|
||||
|
||||
var trackMbid = Mbid(track.Id, "release track");
|
||||
var releaseTrack = await db.CanonicalReleaseTracks.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId && item.MusicBrainzTrackId == trackMbid,
|
||||
cancellationToken);
|
||||
if (releaseTrack == null)
|
||||
{
|
||||
releaseTrack = new CanonicalReleaseTrackRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
MusicBrainzTrackId = trackMbid,
|
||||
CreatedAt = source.ObservedAt,
|
||||
Revision = 1
|
||||
};
|
||||
db.CanonicalReleaseTracks.Add(releaseTrack);
|
||||
created++;
|
||||
}
|
||||
ApplyReleaseTrack(releaseTrack, release.Id, recording.Id, medium.Position, track, source.ObservedAt);
|
||||
releaseTracks.Add((releaseTrack, track));
|
||||
}
|
||||
|
||||
await ReplaceReleaseGroupCreditsAsync(db, actor.TenantId, releaseGroup.Id, releaseCredits, artistsByMbid, cancellationToken);
|
||||
foreach (var pair in recordings)
|
||||
{
|
||||
var sourceRecording = releaseTracks.First(item =>
|
||||
string.Equals(item.Source.Recording!.Id, pair.Key, StringComparison.OrdinalIgnoreCase)).Source.Recording!;
|
||||
var track = releaseTracks.First(item =>
|
||||
string.Equals(item.Source.Recording!.Id, pair.Key, StringComparison.OrdinalIgnoreCase)).Source;
|
||||
await ReplaceRecordingCreditsAsync(
|
||||
db,
|
||||
actor.TenantId,
|
||||
pair.Value.Id,
|
||||
Credits(track.ArtistCredit, sourceRecording.ArtistCredit, releaseCredits),
|
||||
artistsByMbid,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var evidence = new CanonicalCatalogEvidenceResult(0, 0, 0, 0);
|
||||
foreach (var artist in artistsByMbid.Values)
|
||||
evidence = Add(evidence, await RecordEvidenceAsync(db, actor, CanonicalCatalogEntityKind.Artist,
|
||||
artist.Id, artist.MusicBrainzArtistId!, Metadata(artist), source, cancellationToken));
|
||||
evidence = Add(evidence, await RecordEvidenceAsync(db, actor, CanonicalCatalogEntityKind.ReleaseGroup,
|
||||
releaseGroup.Id, releaseGroupMbid, Metadata(releaseGroup), source, cancellationToken));
|
||||
evidence = Add(evidence, await RecordEvidenceAsync(db, actor, CanonicalCatalogEntityKind.Release,
|
||||
release.Id, releaseMbid, Metadata(release), source, cancellationToken));
|
||||
foreach (var recording in recordings.Values)
|
||||
evidence = Add(evidence, await RecordEvidenceAsync(db, actor, CanonicalCatalogEntityKind.Recording,
|
||||
recording.Id, recording.MusicBrainzRecordingId!, Metadata(recording), source, cancellationToken));
|
||||
foreach (var (entity, _) in releaseTracks)
|
||||
evidence = Add(evidence, await RecordEvidenceAsync(db, actor, CanonicalCatalogEntityKind.ReleaseTrack,
|
||||
entity.Id, entity.MusicBrainzTrackId!, Metadata(entity), source, cancellationToken));
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new MusicBrainzCatalogIngestResult(
|
||||
releaseGroup.Id,
|
||||
release.Id,
|
||||
artistsByMbid.Count,
|
||||
recordings.Count,
|
||||
releaseTracks.Count,
|
||||
created,
|
||||
evidence);
|
||||
}
|
||||
|
||||
private static async Task<CanonicalCatalogEvidenceResult> RecordEvidenceAsync(
|
||||
AllstarrDbContext db,
|
||||
ProviderActorContext actor,
|
||||
CanonicalCatalogEntityKind kind,
|
||||
Guid id,
|
||||
string mbid,
|
||||
object metadata,
|
||||
MusicBrainzCatalogSource source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(metadata);
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))).ToLowerInvariant();
|
||||
return await CanonicalCatalogEvidenceStore.RecordInContextAsync(
|
||||
db,
|
||||
actor,
|
||||
new CanonicalCatalogEntityReference(kind, id),
|
||||
new CanonicalCatalogSourceStamp(
|
||||
source.SourceId,
|
||||
source.SourceRevision,
|
||||
hash,
|
||||
1,
|
||||
source.ObservedAt,
|
||||
source.RefreshAfter),
|
||||
[new CanonicalCatalogAliasInput("musicbrainz", mbid)],
|
||||
[new CanonicalCatalogFactInput("metadata", json)],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task ReplaceReleaseGroupCreditsAsync(
|
||||
AllstarrDbContext db,
|
||||
Guid tenantId,
|
||||
Guid releaseGroupId,
|
||||
IReadOnlyList<MusicBrainzArtistCredit> credits,
|
||||
IReadOnlyDictionary<string, CanonicalArtistRecord> artists,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await db.CanonicalReleaseGroupArtists.Where(item =>
|
||||
item.TenantId == tenantId && item.CanonicalReleaseGroupId == releaseGroupId).ToListAsync(cancellationToken);
|
||||
var desired = credits.Select((credit, position) => new CanonicalReleaseGroupArtistRecord
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CanonicalReleaseGroupId = releaseGroupId,
|
||||
CanonicalArtistId = artists[Mbid(credit.Artist?.Id, "artist")].Id,
|
||||
Position = position,
|
||||
CreditName = Text(credit.Name, 500) ?? Text(credit.Artist?.Name, 500),
|
||||
JoinPhrase = Text(credit.JoinPhrase, 50) ?? string.Empty
|
||||
}).ToList();
|
||||
if (CreditsEqual(
|
||||
current.Select(item => (item.Position, item.CanonicalArtistId, item.CreditName, item.JoinPhrase)),
|
||||
desired.Select(item => (item.Position, item.CanonicalArtistId, item.CreditName, item.JoinPhrase))))
|
||||
return;
|
||||
db.CanonicalReleaseGroupArtists.RemoveRange(current);
|
||||
db.CanonicalReleaseGroupArtists.AddRange(desired);
|
||||
}
|
||||
|
||||
private static async Task ReplaceRecordingCreditsAsync(
|
||||
AllstarrDbContext db,
|
||||
Guid tenantId,
|
||||
Guid recordingId,
|
||||
IReadOnlyList<MusicBrainzArtistCredit> credits,
|
||||
IReadOnlyDictionary<string, CanonicalArtistRecord> artists,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await db.CanonicalRecordingArtists.Where(item =>
|
||||
item.TenantId == tenantId && item.CanonicalRecordingId == recordingId).ToListAsync(cancellationToken);
|
||||
var desired = credits.Select((credit, position) => new CanonicalRecordingArtistRecord
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CanonicalRecordingId = recordingId,
|
||||
CanonicalArtistId = artists[Mbid(credit.Artist?.Id, "artist")].Id,
|
||||
Position = position,
|
||||
CreditName = Text(credit.Name, 500) ?? Text(credit.Artist?.Name, 500),
|
||||
JoinPhrase = Text(credit.JoinPhrase, 50) ?? string.Empty
|
||||
}).ToList();
|
||||
if (CreditsEqual(
|
||||
current.Select(item => (item.Position, item.CanonicalArtistId, item.CreditName, item.JoinPhrase)),
|
||||
desired.Select(item => (item.Position, item.CanonicalArtistId, item.CreditName, item.JoinPhrase))))
|
||||
return;
|
||||
db.CanonicalRecordingArtists.RemoveRange(current);
|
||||
db.CanonicalRecordingArtists.AddRange(desired);
|
||||
}
|
||||
|
||||
private static bool CreditsEqual(
|
||||
IEnumerable<(int Position, Guid ArtistId, string? Name, string Join)> first,
|
||||
IEnumerable<(int Position, Guid ArtistId, string? Name, string Join)> second) =>
|
||||
first.OrderBy(item => item.Position).SequenceEqual(second.OrderBy(item => item.Position));
|
||||
|
||||
private static void ApplyArtist(CanonicalArtistRecord target, MusicBrainzArtist source, DateTimeOffset now)
|
||||
{
|
||||
var name = Required(source.Name, "artist name", 500);
|
||||
var changed = target.Name != name || target.SortName != (Text(source.SortName, 500) ?? name) ||
|
||||
target.Disambiguation != Text(source.Disambiguation, 500) || target.IsProvisional;
|
||||
target.Name = name;
|
||||
target.SortName = Text(source.SortName, 500) ?? name;
|
||||
target.Disambiguation = Text(source.Disambiguation, 500);
|
||||
target.IsProvisional = false;
|
||||
(target.Revision, target.UpdatedAt) = Touch(changed, target.Revision, target.UpdatedAt, now);
|
||||
}
|
||||
|
||||
private static void ApplyReleaseGroup(CanonicalReleaseGroupRecord target, MusicBrainzReleaseGroup source, DateTimeOffset now)
|
||||
{
|
||||
var title = Required(source.Title, "release-group title", 500);
|
||||
var secondary = JsonSerializer.Serialize((source.SecondaryTypes ?? []).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToArray());
|
||||
var changed = target.Title != title || target.PrimaryType != Text(source.PrimaryType, 100) ||
|
||||
target.SecondaryTypesJson != secondary || target.FirstReleaseDate != Date(source.FirstReleaseDate) || target.IsProvisional;
|
||||
target.Title = title;
|
||||
target.PrimaryType = Text(source.PrimaryType, 100);
|
||||
target.SecondaryTypesJson = secondary;
|
||||
target.FirstReleaseDate = Date(source.FirstReleaseDate);
|
||||
target.IsProvisional = false;
|
||||
(target.Revision, target.UpdatedAt) = Touch(changed, target.Revision, target.UpdatedAt, now);
|
||||
}
|
||||
|
||||
private static void ApplyRelease(CanonicalReleaseRecord target, Guid groupId, MusicBrainzRelease source, DateTimeOffset now)
|
||||
{
|
||||
var title = Required(source.Title, "release title", 500);
|
||||
var country = Text(source.Country, 2)?.ToUpperInvariant();
|
||||
var changed = target.CanonicalReleaseGroupId != groupId || target.Title != title ||
|
||||
target.Status != Text(source.Status, 100) || target.CountryCode != country ||
|
||||
target.ReleaseDate != Date(source.Date) || target.Barcode != Text(source.Barcode, 100) || target.IsProvisional;
|
||||
target.CanonicalReleaseGroupId = groupId;
|
||||
target.Title = title;
|
||||
target.Status = Text(source.Status, 100);
|
||||
target.CountryCode = country;
|
||||
target.ReleaseDate = Date(source.Date);
|
||||
target.Barcode = Text(source.Barcode, 100);
|
||||
target.IsProvisional = false;
|
||||
(target.Revision, target.UpdatedAt) = Touch(changed, target.Revision, target.UpdatedAt, now);
|
||||
}
|
||||
|
||||
private static void ApplyRecording(CanonicalRecordingRecord target, MusicBrainzRecording source, MusicBrainzReleaseTrack track, DateTimeOffset now)
|
||||
{
|
||||
var title = Required(source.Title ?? track.Title, "recording title", 500);
|
||||
var duration = source.Length ?? track.Length;
|
||||
var isrc = source.Isrcs?.Select(value => value.Trim().ToUpperInvariant()).FirstOrDefault(value => value.Length == 12);
|
||||
var changed = target.Title != title || target.Disambiguation != Text(source.Disambiguation, 500) ||
|
||||
target.DurationMilliseconds != duration || target.Isrc != (isrc ?? target.Isrc) || target.IsProvisional;
|
||||
target.Title = title;
|
||||
target.Disambiguation = Text(source.Disambiguation, 500);
|
||||
target.DurationMilliseconds = duration;
|
||||
target.Isrc = isrc ?? target.Isrc;
|
||||
target.IsProvisional = false;
|
||||
(target.Revision, target.UpdatedAt) = Touch(changed, target.Revision, target.UpdatedAt, now);
|
||||
}
|
||||
|
||||
private static void ApplyReleaseTrack(CanonicalReleaseTrackRecord target, Guid releaseId, Guid recordingId, int mediumPosition, MusicBrainzReleaseTrack source, DateTimeOffset now)
|
||||
{
|
||||
var title = Required(source.Title ?? source.Recording?.Title, "release-track title", 500);
|
||||
var changed = target.CanonicalReleaseId != releaseId || target.CanonicalRecordingId != recordingId ||
|
||||
target.MediumPosition != mediumPosition || target.TrackPosition != source.Position ||
|
||||
target.Title != title || target.DurationMilliseconds != (source.Length ?? source.Recording?.Length);
|
||||
target.CanonicalReleaseId = releaseId;
|
||||
target.CanonicalRecordingId = recordingId;
|
||||
target.MediumPosition = mediumPosition;
|
||||
target.TrackPosition = source.Position;
|
||||
target.Title = title;
|
||||
target.DurationMilliseconds = source.Length ?? source.Recording?.Length;
|
||||
(target.Revision, target.UpdatedAt) = Touch(changed, target.Revision, target.UpdatedAt, now);
|
||||
}
|
||||
|
||||
private static (long Revision, DateTimeOffset UpdatedAt) Touch(
|
||||
bool changed,
|
||||
long revision,
|
||||
DateTimeOffset updatedAt,
|
||||
DateTimeOffset now) =>
|
||||
!changed && updatedAt != default
|
||||
? (revision, updatedAt)
|
||||
: (updatedAt == default ? revision : revision + 1, now);
|
||||
|
||||
private static object Metadata(CanonicalArtistRecord item) => new
|
||||
{
|
||||
item.Name,
|
||||
item.SortName,
|
||||
item.Disambiguation,
|
||||
item.MusicBrainzArtistId
|
||||
};
|
||||
|
||||
private static object Metadata(CanonicalReleaseGroupRecord item) => new
|
||||
{
|
||||
item.Title,
|
||||
item.PrimaryType,
|
||||
item.SecondaryTypesJson,
|
||||
item.FirstReleaseDate,
|
||||
item.MusicBrainzReleaseGroupId
|
||||
};
|
||||
|
||||
private static object Metadata(CanonicalReleaseRecord item) => new
|
||||
{
|
||||
item.Title,
|
||||
item.Status,
|
||||
item.CountryCode,
|
||||
item.ReleaseDate,
|
||||
item.Barcode,
|
||||
item.MusicBrainzReleaseId
|
||||
};
|
||||
|
||||
private static object Metadata(CanonicalRecordingRecord item) => new
|
||||
{
|
||||
item.Title,
|
||||
item.Disambiguation,
|
||||
item.DurationMilliseconds,
|
||||
item.Isrc,
|
||||
item.MusicBrainzRecordingId
|
||||
};
|
||||
|
||||
private static object Metadata(CanonicalReleaseTrackRecord item) => new
|
||||
{
|
||||
item.MediumPosition,
|
||||
item.TrackPosition,
|
||||
item.Title,
|
||||
item.DurationMilliseconds,
|
||||
item.MusicBrainzTrackId
|
||||
};
|
||||
|
||||
private static IReadOnlyList<MusicBrainzArtistCredit> Credits(params IEnumerable<MusicBrainzArtistCredit>?[] candidates) =>
|
||||
candidates.FirstOrDefault(items => items?.Any() == true)?.ToArray() ?? [];
|
||||
|
||||
private static IEnumerable<MusicBrainzReleaseTrack> Tracks(IEnumerable<MusicBrainzMedium>? media) =>
|
||||
TracksWithMedia(media).Select(item => item.Track);
|
||||
|
||||
private static IEnumerable<(MusicBrainzMedium Medium, MusicBrainzReleaseTrack Track)> TracksWithMedia(IEnumerable<MusicBrainzMedium>? media) =>
|
||||
(media ?? []).SelectMany(medium => (medium.Tracks ?? []).Select(track => (medium, track)));
|
||||
|
||||
private static void ValidateTracks(IEnumerable<MusicBrainzMedium>? media)
|
||||
{
|
||||
var tracks = TracksWithMedia(media).ToArray();
|
||||
if (tracks.Length == 0) throw new ArgumentException("The release has no media tracks.", nameof(media));
|
||||
if (tracks.Any(item => item.Medium.Position <= 0 || item.Track.Position <= 0 || item.Track.Recording == null))
|
||||
throw new ArgumentException("Every release track requires positive positions and a recording.", nameof(media));
|
||||
if (tracks.GroupBy(item => (item.Medium.Position, item.Track.Position)).Any(group => group.Count() > 1))
|
||||
throw new ArgumentException("Release-track positions must be unique.", nameof(media));
|
||||
}
|
||||
|
||||
private static string Mbid(string? value, string field) =>
|
||||
Guid.TryParse(value, out var parsed) && parsed != Guid.Empty
|
||||
? parsed.ToString("D")
|
||||
: throw new ArgumentException($"A valid MusicBrainz {field} ID is required.");
|
||||
|
||||
private static string Required(string? value, string field, int maximum) =>
|
||||
Text(value, maximum) ?? throw new ArgumentException($"A {field} is required.");
|
||||
|
||||
private static string? Text(string? value, int maximum)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
return normalized is null || normalized.Length <= maximum
|
||||
? normalized
|
||||
: throw new ArgumentOutOfRangeException(nameof(value));
|
||||
}
|
||||
|
||||
private static string? Date(string? value)
|
||||
{
|
||||
var normalized = Text(value, 10);
|
||||
return normalized == null || normalized.Length is 4 or 7 or 10
|
||||
? normalized
|
||||
: throw new ArgumentException("Catalog dates must use MusicBrainz partial ISO form.");
|
||||
}
|
||||
|
||||
private static void ValidateSource(MusicBrainzCatalogSource source)
|
||||
{
|
||||
_ = Required(source.SourceId, "catalog source ID", 100);
|
||||
_ = Required(source.SourceRevision, "catalog source revision", 500);
|
||||
if (source.RefreshAfter < source.ObservedAt)
|
||||
throw new ArgumentException("Catalog refresh cannot precede observation.", nameof(source));
|
||||
}
|
||||
|
||||
private static CanonicalCatalogEvidenceResult Add(CanonicalCatalogEvidenceResult first, CanonicalCatalogEvidenceResult second) => new(
|
||||
first.AliasesCreated + second.AliasesCreated,
|
||||
first.AliasesSeen + second.AliasesSeen,
|
||||
first.FactsCreated + second.FactsCreated,
|
||||
first.FactsSuperseded + second.FactsSuperseded);
|
||||
|
||||
private void EnsureStorageReady()
|
||||
{
|
||||
if (storageState.GetSnapshot().Readiness != DurableStorageReadiness.Ready)
|
||||
throw new InvalidOperationException("Durable storage is not ready.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Services.MusicBrainz;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace allstarr.Core.Matching;
|
||||
|
||||
public interface IMusicBrainzCatalogRefreshService
|
||||
{
|
||||
Task<MusicBrainzCatalogIngestResult> RefreshReleaseAsync(
|
||||
ProviderActorContext actor,
|
||||
string releaseMbid,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class MusicBrainzCatalogRefreshService(
|
||||
IMusicBrainzCatalogClient client,
|
||||
IMusicBrainzCatalogIngestService ingest,
|
||||
IPlatformClock clock) : IMusicBrainzCatalogRefreshService
|
||||
{
|
||||
public const int MaximumArtistsPerRelease = 64;
|
||||
public static readonly TimeSpan RefreshInterval = TimeSpan.FromDays(7);
|
||||
|
||||
public async Task<MusicBrainzCatalogIngestResult> RefreshReleaseAsync(
|
||||
ProviderActorContext actor,
|
||||
string releaseMbid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actor);
|
||||
var release = await client.LookupReleaseByMbidAsync(releaseMbid, cancellationToken) ??
|
||||
throw new KeyNotFoundException("The catalog release does not exist.");
|
||||
var releaseGroupId = release.ReleaseGroup?.Id ??
|
||||
throw new InvalidDataException("The catalog release is missing its release-group identity.");
|
||||
var releaseGroup = await client.LookupReleaseGroupByMbidAsync(releaseGroupId, cancellationToken) ??
|
||||
throw new InvalidDataException("The catalog release group does not exist.");
|
||||
var artistIds = Credits(release, releaseGroup)
|
||||
.Select(credit => credit.Artist?.Id)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (artistIds.Length == 0 || artistIds.Length > MaximumArtistsPerRelease)
|
||||
throw new InvalidDataException("The catalog release has an invalid artist-credit fan-out.");
|
||||
|
||||
var artists = new List<MusicBrainzArtist>(artistIds.Length);
|
||||
foreach (var artistId in artistIds)
|
||||
{
|
||||
var artist = await client.LookupArtistByMbidAsync(artistId!, cancellationToken) ??
|
||||
throw new InvalidDataException("A credited catalog artist does not exist.");
|
||||
artists.Add(artist);
|
||||
}
|
||||
|
||||
var observedAt = clock.UtcNow;
|
||||
return await ingest.IngestAsync(
|
||||
actor,
|
||||
new MusicBrainzCatalogGraph(release, releaseGroup, artists),
|
||||
new MusicBrainzCatalogSource(
|
||||
client.ConfiguredSourceId,
|
||||
client.ConfiguredSourceRevision,
|
||||
observedAt,
|
||||
observedAt.Add(RefreshInterval)),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static IEnumerable<MusicBrainzArtistCredit> Credits(
|
||||
MusicBrainzRelease release,
|
||||
MusicBrainzReleaseGroup releaseGroup) =>
|
||||
(releaseGroup.ArtistCredit ?? [])
|
||||
.Concat(release.ArtistCredit ?? [])
|
||||
.Concat((release.Media ?? [])
|
||||
.SelectMany(medium => medium.Tracks ?? [])
|
||||
.SelectMany(track => track.ArtistCredit ?? track.Recording?.ArtistCredit ?? []));
|
||||
}
|
||||
|
||||
public sealed record MusicBrainzCatalogRefreshJobPayload(string ReleaseMbid);
|
||||
|
||||
public sealed class MusicBrainzCatalogRefreshJobHandler(
|
||||
IMusicBrainzCatalogRefreshService refresh) : IDurableJobHandler
|
||||
{
|
||||
public const string Type = "catalog.musicbrainz.refresh-release";
|
||||
public string JobType => Type;
|
||||
|
||||
public async Task<DurableJobCompletion> ExecuteAsync(
|
||||
DurableJobExecutionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MusicBrainzCatalogRefreshJobPayload? payload;
|
||||
try { payload = context.Claim.Payload.Deserialize<MusicBrainzCatalogRefreshJobPayload>(); }
|
||||
catch (JsonException) { payload = null; }
|
||||
if (payload == null ||
|
||||
!Guid.TryParse(payload.ReleaseMbid, out var releaseId) ||
|
||||
releaseId == Guid.Empty ||
|
||||
!context.Claim.TenantId.HasValue ||
|
||||
!context.Claim.OwnerUserId.HasValue)
|
||||
return DurableJobCompletion.Failure(
|
||||
"catalog_refresh_payload_invalid",
|
||||
"The canonical catalog refresh payload is invalid.");
|
||||
|
||||
var actor = new ProviderActorContext(
|
||||
context.Claim.TenantId.Value,
|
||||
ProviderActorKind.SystemJob,
|
||||
null,
|
||||
durableJobId: context.Claim.JobId,
|
||||
actingForUserId: context.Claim.OwnerUserId.Value);
|
||||
try
|
||||
{
|
||||
await refresh.RefreshReleaseAsync(actor, releaseId.ToString("D"), cancellationToken);
|
||||
await context.ReportProgressAsync(new(
|
||||
"catalog.refresh",
|
||||
"Canonical release metadata refreshed.",
|
||||
1,
|
||||
1), cancellationToken);
|
||||
return DurableJobCompletion.Success();
|
||||
}
|
||||
catch (MusicBrainzLookupException error) when (error.Retryable)
|
||||
{
|
||||
return DurableJobCompletion.Retry(error.Code, error.Message, error.RetryAfter);
|
||||
}
|
||||
catch (MusicBrainzLookupException error)
|
||||
{
|
||||
return DurableJobCompletion.Failure(error.Code, error.Message);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return DurableJobCompletion.Failure(
|
||||
"catalog_release_missing",
|
||||
"The canonical catalog release no longer exists upstream.");
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return DurableJobCompletion.Failure(
|
||||
"catalog_release_invalid",
|
||||
"The canonical catalog release hierarchy is incomplete.");
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return DurableJobCompletion.Retry(
|
||||
"catalog_storage_conflict",
|
||||
"Canonical catalog storage changed during refresh.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ public static class TrackIdentityRegistration
|
||||
public static IServiceCollection AddTrackIdentity(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ITrackIdentityService, TrackIdentityService>();
|
||||
services.TryAddSingleton<ICanonicalCatalogEvidenceStore, CanonicalCatalogEvidenceStore>();
|
||||
services.TryAddSingleton<IMusicBrainzCatalogIngestService, MusicBrainzCatalogIngestService>();
|
||||
services.TryAddSingleton<IMusicBrainzCatalogRefreshService, MusicBrainzCatalogRefreshService>();
|
||||
services.TryAddSingleton<IMusicBrainzCatalogRefreshQueue, MusicBrainzCatalogRefreshQueue>();
|
||||
services.TryAddSingleton<ILibraryIndexService, LibraryIndexService>();
|
||||
services.TryAddSingleton<TrackMatchDecisionEngine>();
|
||||
services.TryAddSingleton<TrackMatchCommandService>();
|
||||
@@ -17,6 +21,8 @@ public static class TrackIdentityRegistration
|
||||
provider.GetRequiredService<TrackMatchCommandService>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDurableJobHandler, PlaylistRematchJobHandler>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDurableJobHandler, TrackRematchAllJobHandler>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDurableJobHandler, MusicBrainzCatalogRefreshJobHandler>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDurableJobHandler, MusicBrainzCatalogDiscoveryJobHandler>());
|
||||
services.AddHostedService<TrackMatchAlgorithmRolloutService>();
|
||||
services.TryAddSingleton<Playlists.IPlaylistPersistenceService, Playlists.PlaylistPersistenceService>();
|
||||
return services;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using allstarr.Core.Capabilities;
|
||||
@@ -129,15 +127,18 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
private readonly IDbContextFactory<AllstarrDbContext> _contextFactory;
|
||||
private readonly DurableStorageState _storageState;
|
||||
private readonly IPlatformClock _clock;
|
||||
private readonly IMusicBrainzCatalogRefreshQueue? _catalogRefreshQueue;
|
||||
|
||||
public TrackIdentityService(
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
DurableStorageState storageState,
|
||||
IPlatformClock clock)
|
||||
IPlatformClock clock,
|
||||
IMusicBrainzCatalogRefreshQueue? catalogRefreshQueue = null)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_storageState = storageState;
|
||||
_clock = clock;
|
||||
_catalogRefreshQueue = catalogRefreshQueue;
|
||||
}
|
||||
|
||||
public async Task<CanonicalRecordingCreationResult> CreateRecordingAsync(
|
||||
@@ -170,6 +171,12 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
if (existing != null)
|
||||
{
|
||||
EnsureSignalsCompatible(existing, normalizedIsrc, normalizedMusicBrainzId);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
context,
|
||||
actor,
|
||||
existing,
|
||||
_clock.UtcNow,
|
||||
cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
@@ -183,7 +190,8 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
hasMusicBrainzRecordingId = normalizedMusicBrainzId != null
|
||||
});
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
return new CanonicalRecordingCreationResult(ToIdentity(existing), Created: false);
|
||||
return await CompleteCreationAsync(
|
||||
actor, correlationId, existing, created: false, cancellationToken);
|
||||
}
|
||||
|
||||
var now = _clock.UtcNow;
|
||||
@@ -194,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 CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
context,
|
||||
actor,
|
||||
record,
|
||||
now,
|
||||
cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
@@ -214,7 +229,8 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
try
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
return new CanonicalRecordingCreationResult(ToIdentity(record), Created: true);
|
||||
return await CompleteCreationAsync(
|
||||
actor, correlationId, record, created: true, cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
@@ -231,6 +247,12 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
}
|
||||
|
||||
EnsureSignalsCompatible(existing, normalizedIsrc, normalizedMusicBrainzId);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
context,
|
||||
actor,
|
||||
existing,
|
||||
_clock.UtcNow,
|
||||
cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
@@ -239,10 +261,27 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
"concurrent-existing",
|
||||
new { canonicalRecordingId = existing.Id });
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
return new CanonicalRecordingCreationResult(ToIdentity(existing), Created: false);
|
||||
return await CompleteCreationAsync(
|
||||
actor, correlationId, existing, created: false, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CanonicalRecordingCreationResult> CompleteCreationAsync(
|
||||
ProviderActorContext actor,
|
||||
string correlationId,
|
||||
CanonicalRecordingRecord recording,
|
||||
bool created,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_catalogRefreshQueue != null && recording.MusicBrainzRecordingId is { } mbid)
|
||||
{
|
||||
await _catalogRefreshQueue.EnqueueRecordingAsync(
|
||||
actor, mbid, correlationId, cancellationToken);
|
||||
}
|
||||
|
||||
return new CanonicalRecordingCreationResult(ToIdentity(recording), created);
|
||||
}
|
||||
|
||||
public async Task<TrackIdentityLinkResult> LinkAsync(
|
||||
ProviderExecutionContext executionContext,
|
||||
TrackIdentityLinkRequest request,
|
||||
@@ -311,6 +350,12 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
UpdatedAt = now
|
||||
};
|
||||
context.ProviderTrackIdentities.Add(link);
|
||||
await CanonicalCatalogIdentityProjection.ProjectProviderIdentityAsync(
|
||||
context,
|
||||
executionContext.Actor,
|
||||
link,
|
||||
now,
|
||||
cancellationToken);
|
||||
AddLinkAudit(context, executionContext, request, "created", link.Id, null);
|
||||
|
||||
try
|
||||
@@ -472,6 +517,12 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
bool concurrent = false)
|
||||
{
|
||||
EnsureExactExternalId(existing, request.ExternalId.Value);
|
||||
await CanonicalCatalogIdentityProjection.ProjectProviderIdentityAsync(
|
||||
context,
|
||||
executionContext.Actor,
|
||||
existing,
|
||||
_clock.UtcNow,
|
||||
cancellationToken);
|
||||
var sameCanonical = existing.CanonicalRecordingId == request.CanonicalRecordingId;
|
||||
var outcome = sameCanonical
|
||||
? concurrent ? "concurrent-existing" : "already-linked"
|
||||
@@ -771,8 +822,7 @@ 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 void EnsureExactExternalId(
|
||||
ProviderTrackIdentityRecord record,
|
||||
|
||||
@@ -9,6 +9,7 @@ using allstarr.Core.Operations;
|
||||
using allstarr.Core.Playlists;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Core.Settings;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Services.Spotify;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -267,7 +268,8 @@ public sealed class TrackMatchCommandService(
|
||||
TrackMatchDecisionEngine decisionEngine,
|
||||
ProviderAccountResolver accountResolver,
|
||||
IPlatformClock clock,
|
||||
PlaylistPlayableSearchService? playableSearch = null) : ITrackMatchRepository
|
||||
PlaylistPlayableSearchService? playableSearch = null,
|
||||
IEffectiveProviderPolicyResolver? effectivePolicies = null) : ITrackMatchRepository
|
||||
{
|
||||
private const int ConcurrentWriteRetries = 3;
|
||||
private readonly ConcurrentDictionary<
|
||||
@@ -276,6 +278,13 @@ public sealed class TrackMatchCommandService(
|
||||
|
||||
public bool SupportsExternalMatching => playableSearch != null;
|
||||
|
||||
private async Task<TrackMatchDecisionEngine> DecisionEngineAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken) => effectivePolicies == null
|
||||
? decisionEngine
|
||||
: decisionEngine.WithLocalPriorityWindow(
|
||||
(await effectivePolicies.ResolveAsync(tenantId, cancellationToken)).LocalPreferenceWindow);
|
||||
|
||||
public async Task<ExternalMetadataSnapshotRecord> CaptureSnapshotAsync(
|
||||
ProtocolExecutionContext context,
|
||||
ExternalSnapshotInput input,
|
||||
@@ -975,9 +984,12 @@ public sealed class TrackMatchCommandService(
|
||||
IReadOnlyList<LibraryTrackRecord> indexed = source == null
|
||||
? []
|
||||
: await tracks.ToListAsync(cancellationToken);
|
||||
var effectiveEngine = source == null
|
||||
? decisionEngine
|
||||
: await DecisionEngineAsync(actor.TenantId, cancellationToken);
|
||||
HashSet<Guid> automatic = source == null
|
||||
? []
|
||||
: decisionEngine.PrepareCandidates(indexed.Select(ToLocalCandidate))
|
||||
: effectiveEngine.PrepareCandidates(indexed.Select(ToLocalCandidate))
|
||||
.Select(source)
|
||||
.Select(item => item.LibraryTrackId)
|
||||
.ToHashSet();
|
||||
@@ -993,7 +1005,7 @@ public sealed class TrackMatchCommandService(
|
||||
.ToListAsync(cancellationToken);
|
||||
if (automatic.Count == 0) return searched;
|
||||
var indexedById = indexed.ToDictionary(item => item.Id);
|
||||
var selected = decisionEngine.ScoreCandidates(
|
||||
var selected = effectiveEngine.ScoreCandidates(
|
||||
source!,
|
||||
indexed.Where(item => automatic.Contains(item.Id)).Select(ToLocalCandidate))
|
||||
.Select(item => indexedById[item.LibraryTrackId]);
|
||||
@@ -1157,6 +1169,14 @@ public sealed class TrackMatchCommandService(
|
||||
identity.UserId == owner.Id)
|
||||
.OrderByDescending(identity => identity.LastSeenAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var catalogActor = new ProviderActorContext(
|
||||
owner.TenantId,
|
||||
ProviderActorKind.User,
|
||||
owner.Id,
|
||||
new ProviderBackendPrincipal(
|
||||
backend?.BackendType ?? "jellyfin",
|
||||
backend?.BackendInstanceId ?? "source-import",
|
||||
backend?.PrincipalId ?? owner.Id.ToString("N")));
|
||||
|
||||
foreach (var providerGroup in tracks.GroupBy(
|
||||
item => item.ProviderId.Trim().ToLowerInvariant(),
|
||||
@@ -1194,6 +1214,7 @@ public sealed class TrackMatchCommandService(
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = owner.TenantId,
|
||||
CreatedByUserId = owner.Id,
|
||||
IsProvisional = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
@@ -1217,9 +1238,14 @@ public sealed class TrackMatchCommandService(
|
||||
};
|
||||
db.CanonicalRecordings.Add(canonical);
|
||||
db.ProviderTrackIdentities.Add(identity);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
db, catalogActor, canonical, now, cancellationToken);
|
||||
created++;
|
||||
}
|
||||
|
||||
await CanonicalCatalogIdentityProjection.ProjectProviderIdentityAsync(
|
||||
db, catalogActor, identity, now, cancellationToken);
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
providerId,
|
||||
@@ -1403,6 +1429,7 @@ public sealed class TrackMatchCommandService(
|
||||
|
||||
var results = new List<AutomatedSourceMatchResult>(snapshots.Length);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var tenantEngines = new Dictionary<Guid, TrackMatchDecisionEngine>();
|
||||
foreach (var snapshot in snapshots)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -1471,7 +1498,12 @@ public sealed class TrackMatchCommandService(
|
||||
null,
|
||||
new HashSet<Guid> { manual.LibraryTrackId.Value })
|
||||
: null;
|
||||
var decision = decisionEngine.Decide(
|
||||
if (!tenantEngines.TryGetValue(snapshot.TenantId, out var effectiveEngine))
|
||||
{
|
||||
effectiveEngine = await DecisionEngineAsync(snapshot.TenantId, cancellationToken);
|
||||
tenantEngines.Add(snapshot.TenantId, effectiveEngine);
|
||||
}
|
||||
var decision = effectiveEngine.Decide(
|
||||
scope, source, candidates, rejectedOverride);
|
||||
var selected = decision.SelectedLibraryTrackId is { } selectedId
|
||||
? library.ById[selectedId]
|
||||
@@ -1480,6 +1512,12 @@ public sealed class TrackMatchCommandService(
|
||||
{
|
||||
selected.CanonicalRecordingId = identity.CanonicalRecordingId;
|
||||
selected.UpdatedAt = now;
|
||||
await CanonicalCatalogIdentityProjection.ProjectLibraryTrackAsync(
|
||||
db,
|
||||
CatalogActor(snapshot),
|
||||
selected,
|
||||
now,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var state = Enum.Parse<TrackMatchState>(decision.State.ToString(), true);
|
||||
@@ -1648,6 +1686,7 @@ public sealed class TrackMatchCommandService(
|
||||
return new(false, TrackMatchCommandFailure.NotFound, "Track snapshot was not found");
|
||||
if (!actor.IsAdministrator && snapshot.OwnerUserId != actor.UserId)
|
||||
return new(false, TrackMatchCommandFailure.Forbidden, "Track snapshot is outside your account");
|
||||
var catalogActor = CatalogActor(actor, snapshot);
|
||||
|
||||
var source = snapshot.ProviderTrackIdentityId.HasValue
|
||||
? await db.ProviderTrackIdentities.SingleOrDefaultAsync(
|
||||
@@ -1728,14 +1767,15 @@ public sealed class TrackMatchCommandService(
|
||||
null,
|
||||
new HashSet<Guid> { manual.LibraryTrackId.Value })
|
||||
: null;
|
||||
var decision = decisionEngine.Decide(scope, sourceTrack, localCandidates, rejectedOverride);
|
||||
var effectiveEngine = await DecisionEngineAsync(actor.TenantId, cancellationToken);
|
||||
var decision = effectiveEngine.Decide(scope, sourceTrack, localCandidates, rejectedOverride);
|
||||
PlayableTrackMatch? playable = null;
|
||||
if (execution != null &&
|
||||
playableSearch != null &&
|
||||
manual?.Decision is not ManualOverrideDecision.Pin &&
|
||||
!(manual?.Decision == ManualOverrideDecision.Reject && !manual.LibraryTrackId.HasValue) &&
|
||||
decision.State != TrackMatchReviewState.Pinned &&
|
||||
!decisionEngine.CanSkipProviderComparison(decision))
|
||||
!effectiveEngine.CanSkipProviderComparison(decision))
|
||||
{
|
||||
var cachedRoutes = source == null
|
||||
? []
|
||||
@@ -1753,7 +1793,8 @@ public sealed class TrackMatchCommandService(
|
||||
playable = source == null
|
||||
? null
|
||||
: await playableSearch.ReuseAsync(
|
||||
execution, sourceTrack, scope, cachedRoutes, cancellationToken);
|
||||
execution, sourceTrack, scope, cachedRoutes,
|
||||
localCandidates.Select(sourceTrack), rejectedOverride, cancellationToken);
|
||||
playable ??= await playableSearch.MatchAsync(
|
||||
execution,
|
||||
sourceTrack,
|
||||
@@ -1782,15 +1823,20 @@ public sealed class TrackMatchCommandService(
|
||||
TenantId = actor.TenantId,
|
||||
CreatedByUserId = actor.UserId,
|
||||
Isrc = payload.Isrc,
|
||||
IsProvisional = true,
|
||||
CreatedAt = clock.UtcNow,
|
||||
UpdatedAt = clock.UtcNow
|
||||
};
|
||||
db.CanonicalRecordings.Add(canonical);
|
||||
source = AddSourceSnapshotIdentity(
|
||||
db, snapshot, canonical.Id, latestVersion + 1, clock.UtcNow);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
db, catalogActor, canonical, clock.UtcNow, cancellationToken);
|
||||
source = await AddSourceSnapshotIdentityAsync(
|
||||
db, catalogActor, snapshot, canonical.Id, latestVersion + 1,
|
||||
clock.UtcNow, cancellationToken);
|
||||
}
|
||||
canonicalRecordingId = await LinkExternalIdentitiesAsync(
|
||||
db,
|
||||
catalogActor,
|
||||
source,
|
||||
selectedExternal,
|
||||
playable!.RoutableExternalCandidates,
|
||||
@@ -1906,6 +1952,7 @@ public sealed class TrackMatchCommandService(
|
||||
|
||||
private static async Task<Guid> LinkExternalIdentitiesAsync(
|
||||
AllstarrDbContext db,
|
||||
ProviderActorContext actor,
|
||||
ProviderTrackIdentityRecord source,
|
||||
Song selected,
|
||||
IReadOnlyList<Song> routable,
|
||||
@@ -1918,14 +1965,14 @@ public sealed class TrackMatchCommandService(
|
||||
? "automatic-suggestion"
|
||||
: "automatic-match";
|
||||
var canonicalRecordingId = await LinkExternalIdentityAsync(
|
||||
db, source, selected, source.CanonicalRecordingId, true, verificationMethod,
|
||||
db, actor, source, selected, source.CanonicalRecordingId, true, verificationMethod,
|
||||
decisionVersion, now, cancellationToken);
|
||||
foreach (var alternate in routable.Where(song =>
|
||||
!string.Equals(song.ExternalProvider, selected.ExternalProvider, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(song.ExternalId, selected.ExternalId, StringComparison.Ordinal)))
|
||||
{
|
||||
await LinkExternalIdentityAsync(
|
||||
db, source, alternate, canonicalRecordingId, false, verificationMethod,
|
||||
db, actor, source, alternate, canonicalRecordingId, false, verificationMethod,
|
||||
decisionVersion, now, cancellationToken);
|
||||
}
|
||||
return canonicalRecordingId;
|
||||
@@ -1933,6 +1980,7 @@ public sealed class TrackMatchCommandService(
|
||||
|
||||
private static async Task<Guid> LinkExternalIdentityAsync(
|
||||
AllstarrDbContext db,
|
||||
ProviderActorContext actor,
|
||||
ProviderTrackIdentityRecord source,
|
||||
Song song,
|
||||
Guid canonicalRecordingId,
|
||||
@@ -1977,10 +2025,12 @@ public sealed class TrackMatchCommandService(
|
||||
identity.UpdatedAt = now;
|
||||
identity.Revision++;
|
||||
}
|
||||
await CanonicalCatalogIdentityProjection.ProjectProviderIdentityAsync(
|
||||
db, actor, identity, now, cancellationToken);
|
||||
return canonicalRecordingId;
|
||||
}
|
||||
|
||||
db.ProviderTrackIdentities.Add(new ProviderTrackIdentityRecord
|
||||
identity = new ProviderTrackIdentityRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = source.TenantId,
|
||||
@@ -1997,7 +2047,10 @@ public sealed class TrackMatchCommandService(
|
||||
VerifiedAt = now,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
};
|
||||
db.ProviderTrackIdentities.Add(identity);
|
||||
await CanonicalCatalogIdentityProjection.ProjectProviderIdentityAsync(
|
||||
db, actor, identity, now, cancellationToken);
|
||||
return canonicalRecordingId;
|
||||
}
|
||||
|
||||
@@ -2126,6 +2179,7 @@ public sealed class TrackMatchCommandService(
|
||||
return TrackMatchCommandResult.Fail(TrackMatchCommandFailure.NotFound, "Track snapshot was not found");
|
||||
if (!actor.IsAdministrator && snapshot.OwnerUserId != actor.UserId)
|
||||
return TrackMatchCommandResult.Fail(TrackMatchCommandFailure.Forbidden, "Track snapshot is outside your account");
|
||||
var catalogActor = CatalogActor(actor, snapshot);
|
||||
|
||||
var sourceIdentity = snapshot.ProviderTrackIdentityId.HasValue
|
||||
? await db.ProviderTrackIdentities.SingleOrDefaultAsync(
|
||||
@@ -2229,7 +2283,8 @@ public sealed class TrackMatchCommandService(
|
||||
TrackMatchCommandFailure.Invalid,
|
||||
"ExternalProvider and ExternalId are required for a provider match");
|
||||
if (!ExternalTrackPlaybackPolicy.CanUseForPlayback(providerId) ||
|
||||
playableSearch?.CanUseProvider(providerId) == false)
|
||||
playableSearch != null &&
|
||||
!await playableSearch.CanUseProviderAsync(actor.TenantId, providerId, cancellationToken))
|
||||
return TrackMatchCommandResult.Fail(
|
||||
TrackMatchCommandFailure.Invalid,
|
||||
"That provider cannot supply playback audio");
|
||||
@@ -2244,17 +2299,21 @@ public sealed class TrackMatchCommandService(
|
||||
TenantId = actor.TenantId,
|
||||
CreatedByUserId = actor.UserId,
|
||||
Isrc = metadata.Isrc,
|
||||
IsProvisional = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
db.CanonicalRecordings.Add(canonical);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
db, catalogActor, canonical, now, cancellationToken);
|
||||
canonicalId = canonical.Id;
|
||||
}
|
||||
|
||||
if (sourceIdentity == null)
|
||||
{
|
||||
sourceIdentity = AddSourceSnapshotIdentity(
|
||||
db, snapshot, canonicalId.Value, decisionVersion, now);
|
||||
sourceIdentity = await AddSourceSnapshotIdentityAsync(
|
||||
db, catalogActor, snapshot, canonicalId.Value, decisionVersion,
|
||||
now, cancellationToken);
|
||||
}
|
||||
|
||||
var externalHash = Hash(externalId);
|
||||
@@ -2281,7 +2340,7 @@ public sealed class TrackMatchCommandService(
|
||||
cancellationToken);
|
||||
if (identity == null)
|
||||
{
|
||||
db.ProviderTrackIdentities.Add(new ProviderTrackIdentityRecord
|
||||
identity = new ProviderTrackIdentityRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
@@ -2298,7 +2357,8 @@ public sealed class TrackMatchCommandService(
|
||||
VerifiedAt = now,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
};
|
||||
db.ProviderTrackIdentities.Add(identity);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2310,6 +2370,8 @@ public sealed class TrackMatchCommandService(
|
||||
identity.UpdatedAt = now;
|
||||
identity.Revision++;
|
||||
}
|
||||
await CanonicalCatalogIdentityProjection.ProjectProviderIdentityAsync(
|
||||
db, catalogActor, identity, now, cancellationToken);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -2343,12 +2405,14 @@ public sealed class TrackMatchCommandService(
|
||||
}
|
||||
}
|
||||
|
||||
private static ProviderTrackIdentityRecord AddSourceSnapshotIdentity(
|
||||
private static async Task<ProviderTrackIdentityRecord> AddSourceSnapshotIdentityAsync(
|
||||
AllstarrDbContext db,
|
||||
ProviderActorContext actor,
|
||||
ExternalMetadataSnapshotRecord snapshot,
|
||||
Guid canonicalRecordingId,
|
||||
int decisionVersion,
|
||||
DateTimeOffset now)
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var identity = new ProviderTrackIdentityRecord
|
||||
{
|
||||
@@ -2370,9 +2434,35 @@ public sealed class TrackMatchCommandService(
|
||||
UpdatedAt = now
|
||||
};
|
||||
db.ProviderTrackIdentities.Add(identity);
|
||||
await CanonicalCatalogIdentityProjection.ProjectProviderIdentityAsync(
|
||||
db, actor, identity, now, cancellationToken);
|
||||
return identity;
|
||||
}
|
||||
|
||||
private static ProviderActorContext CatalogActor(
|
||||
TrackMatchActor actor,
|
||||
ExternalMetadataSnapshotRecord snapshot) => new(
|
||||
actor.TenantId,
|
||||
actor.IsAdministrator ? ProviderActorKind.Administrator : ProviderActorKind.User,
|
||||
actor.UserId,
|
||||
new ProviderBackendPrincipal(
|
||||
snapshot.Protocol,
|
||||
snapshot.BackendInstanceId,
|
||||
snapshot.BackendPrincipalId),
|
||||
actingForUserId: actor.IsAdministrator && snapshot.OwnerUserId != actor.UserId
|
||||
? snapshot.OwnerUserId
|
||||
: null);
|
||||
|
||||
private static ProviderActorContext CatalogActor(
|
||||
ExternalMetadataSnapshotRecord snapshot) => new(
|
||||
snapshot.TenantId,
|
||||
ProviderActorKind.User,
|
||||
snapshot.OwnerUserId,
|
||||
new ProviderBackendPrincipal(
|
||||
snapshot.Protocol,
|
||||
snapshot.BackendInstanceId,
|
||||
snapshot.BackendPrincipalId));
|
||||
|
||||
private static string CleanReason(string? value, string fallback)
|
||||
{
|
||||
var reason = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
@@ -2446,6 +2536,10 @@ public sealed class TrackMatchCommandService(
|
||||
"IX_provider_track_identity_catalog_exact" or
|
||||
"IX_track_match_scoped_decision")
|
||||
return true;
|
||||
if (postgres.ConstraintName?.StartsWith(
|
||||
"IX_canonical_catalog_aliases_",
|
||||
StringComparison.Ordinal) == true)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -2507,18 +2601,9 @@ public sealed class TrackMatchCommandService(
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
var artists = root.TryGetProperty("Artists", out var artistValues) &&
|
||||
artistValues.ValueKind == JsonValueKind.Array
|
||||
? artistValues.EnumerateArray()
|
||||
.Select(item => item.GetString())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.ToArray()
|
||||
: [];
|
||||
return (
|
||||
ReadString(root, "title", "Title", "name", "Name"),
|
||||
artists.Length > 0
|
||||
? string.Join(", ", artists)
|
||||
: ReadString(root, "artist", "Artist", "primaryArtist", "PrimaryArtist"),
|
||||
TrackSnapshotMetadata.Artist(root),
|
||||
ReadString(root, "album", "Album"),
|
||||
ReadString(root, "albumArtist", "AlbumArtist"),
|
||||
ReadString(root, "isrc", "Isrc", "ISRC"));
|
||||
|
||||
@@ -107,7 +107,7 @@ public sealed record TrackMatchDecision(
|
||||
|
||||
public sealed class TrackMatchPolicy
|
||||
{
|
||||
public double LocalPriorityWindow { get; set; } = 0.07;
|
||||
public double LocalPriorityWindow { get; init; } = 0.07;
|
||||
|
||||
public IReadOnlyList<double> ProviderPriorityWindows { get; init; } = [0.05, 0.03, 0.01];
|
||||
|
||||
@@ -137,7 +137,7 @@ public sealed class TrackMatchPolicy
|
||||
|
||||
public sealed class TrackMatchDecisionEngine
|
||||
{
|
||||
public const string AlgorithmVersion = "priority-windows-v17";
|
||||
public const string AlgorithmVersion = "accepted-routing-v18";
|
||||
private const double ScoreEpsilon = 0.0000001;
|
||||
|
||||
private readonly TrackMatchPolicy _policy;
|
||||
@@ -148,6 +148,16 @@ public sealed class TrackMatchDecisionEngine
|
||||
_policy.Validate();
|
||||
}
|
||||
|
||||
public TrackMatchDecisionEngine WithLocalPriorityWindow(double value) => new(new TrackMatchPolicy
|
||||
{
|
||||
LocalPriorityWindow = value,
|
||||
ProviderPriorityWindows = _policy.ProviderPriorityWindows,
|
||||
AcceptThreshold = _policy.AcceptThreshold,
|
||||
SuggestThreshold = _policy.SuggestThreshold,
|
||||
AmbiguityDelta = _policy.AmbiguityDelta,
|
||||
DurationToleranceSeconds = _policy.DurationToleranceSeconds
|
||||
});
|
||||
|
||||
public static long LibraryIndexRevision(IEnumerable<LocalTrackMatchCandidate> candidates)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(candidates
|
||||
@@ -195,9 +205,11 @@ public sealed class TrackMatchDecisionEngine
|
||||
if (ranked.Length == 0) return [];
|
||||
|
||||
var highestConfidence = ranked[0].Score.Confidence;
|
||||
var selected = ranked
|
||||
var accepted = ranked.Where(candidate => IsAcceptanceQualified(candidate.Score)).ToArray();
|
||||
var eligible = accepted.Length > 0 ? accepted : ranked
|
||||
.Where(candidate => candidate.Score.Confidence + candidate.Priority.Window + ScoreEpsilon >=
|
||||
highestConfidence)
|
||||
highestConfidence);
|
||||
var selected = eligible
|
||||
.OrderBy(candidate => candidate.Priority.Rank)
|
||||
.ThenByDescending(candidate => candidate.Score.Confidence)
|
||||
.ThenBy(candidate => candidate.Score.LibraryTrackId)
|
||||
@@ -217,19 +229,21 @@ public sealed class TrackMatchDecisionEngine
|
||||
RankedCandidate candidate,
|
||||
bool displacedHigherConfidence)
|
||||
{
|
||||
if (candidate.Priority.Window == 0) return candidate.Score;
|
||||
|
||||
var components = new Dictionary<string, double>(
|
||||
candidate.Score.Components ?? new Dictionary<string, double>())
|
||||
{
|
||||
["priorityWindow"] = candidate.Priority.Window
|
||||
["priorityWindow"] = candidate.Priority.Window,
|
||||
["routingPriority"] = candidate.Priority.Rank,
|
||||
["acceptanceQualified"] = IsAcceptanceQualified(candidate.Score) ? 1 : 0
|
||||
};
|
||||
return candidate.Score with
|
||||
{
|
||||
Components = components,
|
||||
Reasons = displacedHigherConfidence
|
||||
? candidate.Score.Reasons
|
||||
.Append(candidate.Candidate.IsLocal
|
||||
.Append(IsAcceptanceQualified(candidate.Score)
|
||||
? "accepted_route_priority_selected"
|
||||
: candidate.Candidate.IsLocal
|
||||
? "local_priority_window_selected"
|
||||
: "provider_priority_window_selected")
|
||||
.ToArray()
|
||||
@@ -269,8 +283,10 @@ public sealed class TrackMatchDecisionEngine
|
||||
|
||||
public bool CanSkipProviderComparison(TrackMatchDecision decision) =>
|
||||
decision.State == TrackMatchReviewState.Accepted &&
|
||||
decision.Candidates.FirstOrDefault() is { IsLocal: true } local &&
|
||||
local.Confidence + _policy.LocalPriorityWindow + ScoreEpsilon >= 1;
|
||||
decision.Candidates.FirstOrDefault() is { IsLocal: true };
|
||||
|
||||
private bool IsAcceptanceQualified(TrackMatchCandidateScore score) =>
|
||||
score.Confidence >= _policy.AcceptThreshold && HasStrongArtistEvidence(score);
|
||||
|
||||
public TrackMatchDecision Decide(
|
||||
TrackMatchScope scope,
|
||||
@@ -404,10 +420,15 @@ public sealed class TrackMatchDecisionEngine
|
||||
public static CandidatePriority Fallback { get; } = new(int.MaxValue, 0);
|
||||
}
|
||||
|
||||
private static bool HasStrongArtistEvidence(TrackMatchCandidateScore score) =>
|
||||
private bool HasStrongArtistEvidence(TrackMatchCandidateScore score) =>
|
||||
score.Components == null ||
|
||||
!score.Components.TryGetValue("artist", out var artistScore) ||
|
||||
artistScore >= 0.7;
|
||||
artistScore >= 0.7 ||
|
||||
(score.Confidence >= _policy.AcceptThreshold &&
|
||||
score.Components.GetValueOrDefault("primaryArtist") >= 0.98 &&
|
||||
score.Components.GetValueOrDefault("title") >= 0.98 &&
|
||||
score.Components.GetValueOrDefault("album") >= 0.98 &&
|
||||
score.Components.GetValueOrDefault("duration") >= 0.95);
|
||||
|
||||
private TrackMatchCandidateScore ScoreCandidate(
|
||||
ExternalTrackMatchSnapshot source,
|
||||
@@ -450,7 +471,9 @@ public sealed class TrackMatchDecisionEngine
|
||||
var components = new Dictionary<string, double>
|
||||
{
|
||||
["title"] = Math.Round(title, 4),
|
||||
["artist"] = Math.Round(artist, 4)
|
||||
["artist"] = Math.Round(artist, 4),
|
||||
["primaryArtist"] = Similarity(SplitArtists(source.Artist).FirstOrDefault(),
|
||||
SplitArtists(candidate.Artist).FirstOrDefault())
|
||||
};
|
||||
AddReason(reasons, "title", title);
|
||||
AddReason(reasons, "artist", artist);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Core.Matching;
|
||||
|
||||
public static class TrackSnapshotMetadata
|
||||
{
|
||||
public static string? Artist(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var key in new[] { "artists", "Artists" })
|
||||
{
|
||||
if (!root.TryGetProperty(key, out var values) || values.ValueKind != JsonValueKind.Array) continue;
|
||||
var names = values.EnumerateArray()
|
||||
.Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : Text(item, "name", "Name"))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (names.Length > 0) return string.Join(", ", names);
|
||||
}
|
||||
return Text(root, "artist", "Artist", "primaryArtist", "PrimaryArtist");
|
||||
}
|
||||
|
||||
private static string? Text(JsonElement value, params string[] keys)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var key in keys)
|
||||
if (value.TryGetProperty(key, out var text) && text.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(text.GetString())) return text.GetString();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ namespace allstarr.Core.Playback;
|
||||
|
||||
public static class PlaybackRegistration
|
||||
{
|
||||
public static IServiceCollection AddDurablePlaybackSignals(this IServiceCollection services)
|
||||
public static IServiceCollection AddDurablePlaybackSignals(
|
||||
this IServiceCollection services,
|
||||
bool includeIntelligenceEnrichment)
|
||||
{
|
||||
services.AddSingleton<PlaybackDeliveryActivityStore>();
|
||||
services.AddSingleton<IPlaybackDeliveryActivitySource>(provider =>
|
||||
@@ -17,8 +19,15 @@ public static class PlaybackRegistration
|
||||
services.AddSingleton<IPlaybackLyricsPrefetch, PlaybackLyricsPrefetch>();
|
||||
services.AddSingleton<IScopedPlaybackScrobbleDelivery, ScopedPlaybackScrobbleDelivery>();
|
||||
services.AddSingleton<IPlaybackDeliveryCheckpointStore, EfPlaybackDeliveryCheckpointStore>();
|
||||
services.AddSingleton<MusicBrainzListeningEnrichmentQueue>();
|
||||
services.AddSingleton<IDurableJobHandler, MusicBrainzListeningEnrichmentJobHandler>();
|
||||
if (includeIntelligenceEnrichment)
|
||||
{
|
||||
services.AddSingleton<MusicBrainzListeningEnrichmentQueue>();
|
||||
services.AddSingleton<IDurableJobHandler, MusicBrainzListeningEnrichmentJobHandler>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IRecommendationSignalWriter, DisabledRecommendationSignalWriter>();
|
||||
}
|
||||
services.AddHttpClient<IExactScopePlaybackScrobbleTarget, LastFmScopedPlaybackScrobbleTarget>(client => client.Timeout = TimeSpan.FromSeconds(10))
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
services.AddHttpClient<IExactScopePlaybackScrobbleTarget, ListenBrainzScopedPlaybackScrobbleTarget>(client => client.Timeout = TimeSpan.FromSeconds(10))
|
||||
@@ -26,4 +35,15 @@ public static class PlaybackRegistration
|
||||
services.AddSingleton<IDurableJobHandler, PlaybackSignalJobHandler>();
|
||||
return services;
|
||||
}
|
||||
|
||||
private sealed class DisabledRecommendationSignalWriter : IRecommendationSignalWriter
|
||||
{
|
||||
public Task<bool> WriteAsync(
|
||||
IntelligenceScope scope,
|
||||
string signalType,
|
||||
string trackReference,
|
||||
double value,
|
||||
DateTimeOffset observedAt,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Core.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace allstarr.Core.Playlists;
|
||||
@@ -128,7 +129,8 @@ public static class PlaylistProjectionSelector
|
||||
|
||||
public sealed class DurablePlaylistProjectionReader(
|
||||
IDbContextFactory<AllstarrDbContext> factory,
|
||||
IProtocolProviderGateway? providerGateway = null)
|
||||
IProtocolProviderGateway? providerGateway = null,
|
||||
IEffectiveProviderPolicyResolver? effectivePolicies = null)
|
||||
{
|
||||
public async Task<DurablePlaylistProjection?> ReadByNameAsync(
|
||||
Guid tenantId,
|
||||
@@ -286,15 +288,24 @@ public sealed class DurablePlaylistProjectionReader(
|
||||
(item.Verification == ProviderIdentityVerification.Verified ||
|
||||
item.Verification == ProviderIdentityVerification.Pinned))
|
||||
.ToListAsync(cancellationToken);
|
||||
var effectivePolicy = effectivePolicies == null
|
||||
? null
|
||||
: await effectivePolicies.ResolveAsync(tenantId, cancellationToken);
|
||||
var providerOrder = providerGateway == null
|
||||
? identities.Select(item => item.ProviderId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray()
|
||||
: (providerGateway.GetProviderOrder(ProviderCapabilityKind.Streaming) ?? [])
|
||||
.Concat(providerGateway.GetProviderOrder(ProviderCapabilityKind.Download) ?? [])
|
||||
: ProviderOrder(ProviderCapabilityKind.Streaming)
|
||||
.Concat(ProviderOrder(ProviderCapabilityKind.Download))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
IReadOnlyList<string> ProviderOrder(ProviderCapabilityKind capability)
|
||||
{
|
||||
var available = providerGateway.GetProviderOrder(capability);
|
||||
return effectivePolicy?.ApplyProviderAvailability(capability, available) ?? available;
|
||||
}
|
||||
var publishedMatchIds = entries
|
||||
.Where(item => item.PublishedTrackMatchId.HasValue)
|
||||
.Select(item => item.PublishedTrackMatchId!.Value)
|
||||
|
||||
@@ -11,6 +11,7 @@ using allstarr.Core.Playlists.Sources;
|
||||
using allstarr.Core.Playlists.Targets;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Core.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace allstarr.Core.Playlists;
|
||||
@@ -180,9 +181,15 @@ public sealed class PlaylistOrchestrationService : IPlaylistOrchestrationService
|
||||
TrackMatchDecisionEngine matcher,
|
||||
ITrackMatchRepository trackMatches,
|
||||
IPlatformClock clock,
|
||||
ILogger<PlaylistOrchestrationService>? logger = null) =>
|
||||
ILogger<PlaylistOrchestrationService>? logger = null,
|
||||
IEffectiveProviderPolicyResolver? effectivePolicies = null)
|
||||
{
|
||||
(_factory, _source, _targets, _planner, _matcher, _trackMatches, _clock, _logger) =
|
||||
(factory, source, targets, planner, matcher, trackMatches, clock, logger);
|
||||
(factory, source, targets, planner, matcher, trackMatches, clock, logger);
|
||||
_effectivePolicies = effectivePolicies;
|
||||
}
|
||||
|
||||
private readonly IEffectiveProviderPolicyResolver? _effectivePolicies;
|
||||
|
||||
public async Task<PlaylistOrchestrationResult> RunAsync(
|
||||
ProtocolExecutionContext execution,
|
||||
@@ -764,6 +771,10 @@ public sealed class PlaylistOrchestrationService : IPlaylistOrchestrationService
|
||||
Func<int, int, string, CancellationToken, Task>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveMatcher = _effectivePolicies == null
|
||||
? _matcher
|
||||
: _matcher.WithLocalPriorityWindow(
|
||||
(await _effectivePolicies.ResolveAsync(link.TenantId, cancellationToken)).LocalPreferenceWindow);
|
||||
await using var db = await _factory.CreateDbContextAsync(cancellationToken);
|
||||
var entries = await db.PlaylistSourceEntries.AsNoTracking().Where(item =>
|
||||
item.TenantId == link.TenantId && item.PlaylistSourceSnapshotId == snapshot.Id)
|
||||
@@ -846,7 +857,6 @@ public sealed class PlaylistOrchestrationService : IPlaylistOrchestrationService
|
||||
group => group.First().CanonicalRecordingId,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Optimization: Map candidates once outside the loop instead of doing it N times
|
||||
var mappedCandidates = candidates.Select(ToCandidate)
|
||||
.Select(candidate =>
|
||||
{
|
||||
@@ -934,7 +944,7 @@ public sealed class PlaylistOrchestrationService : IPlaylistOrchestrationService
|
||||
new HashSet<Guid> { manual.LibraryTrackId.Value })
|
||||
: null;
|
||||
|
||||
var match = _matcher.Decide(
|
||||
var match = effectiveMatcher.Decide(
|
||||
new TrackMatchScope(link.TenantId, link.OwnerUserId, link.TargetBackendInstanceId, link.LibraryScopeId, link.ProviderAccountId, 1, snapshot.SnapshotVersion),
|
||||
source,
|
||||
candidateSet,
|
||||
|
||||
@@ -4,6 +4,7 @@ using allstarr.Core.Capabilities;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Core.Settings;
|
||||
|
||||
namespace allstarr.Core.Protocols;
|
||||
|
||||
@@ -21,7 +22,8 @@ public interface IProtocolLyricsResolver
|
||||
public sealed class ProtocolLyricsResolver(
|
||||
IProtocolProviderGateway providers,
|
||||
ILogger<ProtocolLyricsResolver> logger,
|
||||
OdesliService? odesli = null) : IProtocolLyricsResolver
|
||||
OdesliService? odesli = null,
|
||||
IEffectiveProviderPolicyResolver? effectivePolicies = null) : IProtocolLyricsResolver
|
||||
{
|
||||
public async Task<LyricsInfo?> FindAsync(
|
||||
ProtocolExecutionContext protocol,
|
||||
@@ -37,6 +39,15 @@ public sealed class ProtocolLyricsResolver(
|
||||
var artists = song.Artists.Count > 0 ? song.Artists : [song.Artist];
|
||||
|
||||
var order = providers.GetProviderOrder(ProviderCapabilityKind.Lyrics);
|
||||
if (effectivePolicies != null && protocol.Actor is { } actor)
|
||||
{
|
||||
var effectivePolicy = await effectivePolicies.ResolveAsync(
|
||||
actor.TenantId,
|
||||
protocol.CancellationToken);
|
||||
order = effectivePolicy.ApplyProviderAvailability(
|
||||
ProviderCapabilityKind.Lyrics,
|
||||
order);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(sourceProvider) &&
|
||||
order.Contains(sourceProvider, StringComparer.OrdinalIgnoreCase))
|
||||
order = [sourceProvider, .. order];
|
||||
|
||||
@@ -2,8 +2,11 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Collections.Immutable;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Routing;
|
||||
using allstarr.Core.Settings;
|
||||
using allstarr.Core.Downloads;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
@@ -96,7 +99,9 @@ public sealed record ProtocolProviderStream(
|
||||
string ServingProviderId,
|
||||
string? ServingExternalId = null,
|
||||
Guid? ServingAccountId = null,
|
||||
bool IsCached = false);
|
||||
bool IsCached = false,
|
||||
ProviderAudioQuality EffectiveQuality = ProviderAudioQuality.Any,
|
||||
string SelectionReason = "provider-priority");
|
||||
|
||||
public sealed class ProtocolProviderGateway(
|
||||
IProviderRouter router,
|
||||
@@ -108,7 +113,8 @@ public sealed class ProtocolProviderGateway(
|
||||
IApplicationCache? applicationCache = null,
|
||||
ILogger<ProtocolProviderGateway>? logger = null,
|
||||
ManagedTrackCacheService? managedTrackCache = null,
|
||||
PlaybackDeliveryActivityStore? playbackActivity = null) : IProtocolProviderGateway
|
||||
PlaybackDeliveryActivityStore? playbackActivity = null,
|
||||
IEffectiveProviderPolicyResolver? effectivePolicies = null) : IProtocolProviderGateway
|
||||
{
|
||||
private const string StreamingClientName = "ProtocolProviderStreaming";
|
||||
private const int ProviderSearchConcurrency = 4;
|
||||
@@ -151,15 +157,18 @@ public sealed class ProtocolProviderGateway(
|
||||
NormalizeProvider(itemProvider) == requestedProviderId);
|
||||
}
|
||||
var actor = protocol.RequireActor();
|
||||
var effectivePolicy = effectivePolicies == null
|
||||
? null
|
||||
: await effectivePolicies.ResolveAsync(actor.TenantId, protocol.CancellationToken);
|
||||
var playableProviders = songLimit > 0
|
||||
? (await ResolvePlayableProviderOrderAsync(
|
||||
protocol,
|
||||
actor,
|
||||
ResolveProviderOrder(ProviderCapabilityKind.Streaming)))
|
||||
ResolveProviderOrder(ProviderCapabilityKind.Streaming, effectivePolicy)))
|
||||
.ToHashSet(StringComparer.Ordinal)
|
||||
: [];
|
||||
var fetchLimit = Math.Clamp(Math.Max(songLimit, Math.Max(albumLimit, artistLimit)), 1, 200);
|
||||
var providerOrder = ResolveProviderOrder(ProviderCapabilityKind.Metadata)
|
||||
var providerOrder = ResolveProviderOrder(ProviderCapabilityKind.Metadata, effectivePolicy)
|
||||
.Where(item => requestedProviderId == null || item == requestedProviderId)
|
||||
.ToArray();
|
||||
if (providerOrder.Length == 0) return new SearchResult();
|
||||
@@ -256,6 +265,18 @@ public sealed class ProtocolProviderGateway(
|
||||
}
|
||||
|
||||
var actor = protocol.RequireActor();
|
||||
if (effectivePolicies != null)
|
||||
{
|
||||
var effectivePolicy = await effectivePolicies.ResolveAsync(
|
||||
actor.TenantId,
|
||||
protocol.CancellationToken);
|
||||
configuredProviderOrder = ResolveProviderOrder(
|
||||
ProviderCapabilityKind.Streaming,
|
||||
effectivePolicy)
|
||||
.Select(NormalizeProvider)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
var providerOrder = await ResolvePlayableProviderOrderAsync(
|
||||
protocol, actor, configuredProviderOrder);
|
||||
if (providerOrder.Count == 0) return [];
|
||||
@@ -495,7 +516,10 @@ public sealed class ProtocolProviderGateway(
|
||||
if (protocol.Actor is null) return [];
|
||||
|
||||
var actor = protocol.RequireActor();
|
||||
var providerOrder = ResolveProviderOrder(ProviderCapabilityKind.Playlist);
|
||||
var effectivePolicy = effectivePolicies == null
|
||||
? null
|
||||
: await effectivePolicies.ResolveAsync(actor.TenantId, protocol.CancellationToken);
|
||||
var providerOrder = ResolveProviderOrder(ProviderCapabilityKind.Playlist, effectivePolicy);
|
||||
var plan = await router.PlanAsync<IProviderPlaylistCapability>(Request(
|
||||
protocol,
|
||||
actor,
|
||||
@@ -650,6 +674,13 @@ public sealed class ProtocolProviderGateway(
|
||||
protocol.Deadline, opening.Token, protocol.Client, protocol.LibraryScopeId);
|
||||
providerId = NormalizeProvider(providerId);
|
||||
var actor = protocol.RequireActor();
|
||||
var effectivePolicy = effectivePolicies == null
|
||||
? null
|
||||
: await effectivePolicies.ResolveAsync(actor.TenantId, protocol.CancellationToken);
|
||||
if (quality == ProviderAudioQuality.Any && effectivePolicy != null)
|
||||
{
|
||||
quality = AudioQualityPolicy.RequestedQuality(effectivePolicy.AudioQuality);
|
||||
}
|
||||
var exactRouteMissKey = CacheKeyBuilder.BuildPlaybackRouteNegativeKey(
|
||||
actor.TenantId,
|
||||
actor.EffectiveUserId,
|
||||
@@ -670,22 +701,29 @@ public sealed class ProtocolProviderGateway(
|
||||
var rangeStart = parsedRange?.Ranges.Single().From;
|
||||
var trackId = new ProviderExternalResourceId(
|
||||
providerId, ProviderResourceKind.Track, externalId);
|
||||
var providerOrder = ResolveProviderOrder(ProviderCapabilityKind.Streaming)
|
||||
.Append(providerId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var providerOrder = ResolveProviderOrder(ProviderCapabilityKind.Streaming, effectivePolicy);
|
||||
if (effectivePolicy?.DisabledProviders.Contains(providerId) != true)
|
||||
{
|
||||
providerOrder = providerOrder.Append(providerId).Distinct(StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
var itemId = ProtocolItemId(trackId);
|
||||
var previous = playbackActivity?.StreamFor(protocol, itemId, quality);
|
||||
var resuming = parsedRange != null && rangeStart != 0;
|
||||
var hasPlaybackIdentity = !string.IsNullOrWhiteSpace(protocol.Client.DeviceId);
|
||||
if (!hasPlaybackIdentity) providerOrder = [providerId];
|
||||
if (!hasPlaybackIdentity)
|
||||
{
|
||||
if (!providerOrder.Contains(providerId, StringComparer.Ordinal)) return null;
|
||||
providerOrder = [providerId];
|
||||
}
|
||||
if (resuming)
|
||||
{
|
||||
// Byte offsets cannot be transferred between two encodings of the same recording.
|
||||
if (hasPlaybackIdentity && playbackActivity != null && previous == null)
|
||||
throw new HttpRequestException("Restart playback before seeking this external track.", null,
|
||||
HttpStatusCode.RequestedRangeNotSatisfiable);
|
||||
providerOrder = [previous?.ProviderId ?? providerId];
|
||||
var resumeProvider = previous?.ProviderId ?? providerId;
|
||||
if (!providerOrder.Contains(resumeProvider, StringComparer.Ordinal)) return null;
|
||||
providerOrder = [resumeProvider];
|
||||
}
|
||||
var plan = await router.PlanAsync<IProviderStreamingCapability>(Request(
|
||||
protocol,
|
||||
@@ -721,24 +759,28 @@ public sealed class ProtocolProviderGateway(
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidate in plan.Candidates)
|
||||
{
|
||||
protocol.CancellationToken.ThrowIfCancellationRequested();
|
||||
var servingTrack = candidate.TrackId ?? trackId;
|
||||
if (resuming && previous != null &&
|
||||
(servingTrack.Value != previous.ExternalId || candidate.Context.Account?.AccountId != previous.AccountId))
|
||||
throw new HttpRequestException("The previous playback source is no longer available. Restart playback.", null,
|
||||
HttpStatusCode.RequestedRangeNotSatisfiable);
|
||||
var cached = managedTrackCache == null ? null : await managedTrackCache.TryOpenAsync(
|
||||
servingTrack, candidate.Context.Account?.AccountId, quality, protocol.CancellationToken);
|
||||
if (cached != null) return Opened(cached);
|
||||
}
|
||||
|
||||
string? fallbackReason = null;
|
||||
for (var candidateIndex = 0; candidateIndex < plan.Candidates.Count; candidateIndex++)
|
||||
{
|
||||
protocol.CancellationToken.ThrowIfCancellationRequested();
|
||||
var candidate = plan.Candidates[candidateIndex];
|
||||
var servingTrack = candidate.TrackId ?? trackId;
|
||||
if (resuming && previous != null &&
|
||||
(servingTrack.Value != previous.ExternalId || candidate.Context.Account?.AccountId != previous.AccountId))
|
||||
throw new HttpRequestException("The previous playback source is no longer available. Restart playback.", null,
|
||||
HttpStatusCode.RequestedRangeNotSatisfiable);
|
||||
var routeReason = string.Join(':',
|
||||
fallbackReason ?? "provider-priority",
|
||||
candidate.Context.Account?.ResolutionReason ?? "account-free");
|
||||
var cacheScope = new DownloadedSongMappingScope(
|
||||
actor.TenantId,
|
||||
candidate.Context.Account?.AccountId,
|
||||
protocol.LibraryScopeId,
|
||||
quality);
|
||||
var cached = managedTrackCache == null ? null : await managedTrackCache.TryOpenAsync(
|
||||
servingTrack, cacheScope, protocol.CancellationToken);
|
||||
if (cached != null)
|
||||
return Opened(cached with { SelectionReason = $"{routeReason}:managed-cache" });
|
||||
var leaseRequest = new ProviderStreamLeaseRequest(
|
||||
servingTrack,
|
||||
quality,
|
||||
@@ -773,13 +815,18 @@ public sealed class ProtocolProviderGateway(
|
||||
{
|
||||
var (response, lease) = outcome.RequireValue();
|
||||
return Opened(new ProtocolProviderStream(response, lease, candidate.Provider.Id,
|
||||
servingTrack.Value, candidate.Context.Account?.AccountId));
|
||||
servingTrack.Value, candidate.Context.Account?.AccountId,
|
||||
EffectiveQuality: quality,
|
||||
SelectionReason: $"{routeReason}:remote"));
|
||||
}
|
||||
if (!resuming && router.EvaluateFallback(plan, candidateIndex, outcome.Error!).Disposition ==
|
||||
ProviderFallbackDisposition.Advance)
|
||||
var fallback = resuming ? null : router.EvaluateFallback(plan, candidateIndex, outcome.Error!);
|
||||
if (fallback?.Disposition == ProviderFallbackDisposition.Advance)
|
||||
{
|
||||
logger?.LogInformation("Playback advanced from {Provider} after {FailureCode}",
|
||||
candidate.Provider.Id, outcome.Error!.Code);
|
||||
fallbackReason = fallback.ReasonCode.StartsWith("fallback-", StringComparison.Ordinal)
|
||||
? fallback.ReasonCode
|
||||
: $"fallback-{fallback.ReasonCode}";
|
||||
continue;
|
||||
}
|
||||
ThrowRouteFailure(outcome.Error!);
|
||||
@@ -791,6 +838,8 @@ public sealed class ProtocolProviderGateway(
|
||||
{
|
||||
stream.Response.Headers.Remove("X-Allstarr-Provider");
|
||||
stream.Response.Headers.Add("X-Allstarr-Provider", stream.ServingProviderId);
|
||||
stream.Response.Headers.Remove("X-Allstarr-Route-Reason");
|
||||
stream.Response.Headers.Add("X-Allstarr-Route-Reason", stream.SelectionReason);
|
||||
if (!headOnly) playbackActivity?.StreamOpened(protocol, itemId, quality, stream);
|
||||
logger?.LogInformation("Opened external playback from {Provider} (cached: {Cached})",
|
||||
stream.ServingProviderId, stream.IsCached);
|
||||
@@ -1020,27 +1069,39 @@ public sealed class ProtocolProviderGateway(
|
||||
.AccountRequirement == ProviderAccountRequirement.None);
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> ResolveProviderOrder(ProviderCapabilityKind capability)
|
||||
private IReadOnlyList<string> ResolveProviderOrder(
|
||||
ProviderCapabilityKind capability,
|
||||
EffectiveProviderPolicySnapshot? effectivePolicy = null)
|
||||
{
|
||||
var (settingKey, environmentKey, fallback) = capability switch
|
||||
var definition = ProviderOrderPolicyCatalog.Find(capability);
|
||||
if (definition == null) return [];
|
||||
var availableProviders = registry.FindByCapability(capability, includeNonOperational: true)
|
||||
.Select(provider => NormalizeProvider(provider.Id))
|
||||
.ToArray();
|
||||
if (effectivePolicy != null)
|
||||
{
|
||||
ProviderCapabilityKind.Metadata => ("Providers:MetadataOrder", "MULTI_PROVIDER_METADATA_ORDER", "apple-download,deezer,qobuz"),
|
||||
ProviderCapabilityKind.Playlist => ("Providers:PlaylistOrder", "MULTI_PROVIDER_PLAYLIST_ORDER", "spotify,apple-download,deezer,qobuz"),
|
||||
ProviderCapabilityKind.Lyrics => ("Providers:LyricsOrder", "MULTI_PROVIDER_LYRICS_ORDER", "spotify,apple-download,lrclib"),
|
||||
ProviderCapabilityKind.Streaming => ("Providers:StreamingOrder", "MULTI_PROVIDER_STREAMING_ORDER", "apple-download,deezer,qobuz"),
|
||||
ProviderCapabilityKind.Download => ("Providers:DownloadOrder", "MULTI_PROVIDER_DOWNLOAD_ORDER", "apple-download,deezer,qobuz"),
|
||||
_ => (string.Empty, string.Empty, string.Empty)
|
||||
};
|
||||
var configured = configuration?[settingKey] ?? configuration?[environmentKey] ?? fallback;
|
||||
var ordered = configured.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
return effectivePolicy.ApplyProviderAvailability(capability, availableProviders);
|
||||
}
|
||||
var configured = (configuration?[definition.SettingKey] ??
|
||||
configuration?[definition.BootstrapKey] ??
|
||||
definition.DefaultValue)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var disabled = (configuration?["Providers:Disabled"] ??
|
||||
configuration?["MULTI_PROVIDER_DISABLED_PROVIDERS"] ??
|
||||
string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(NormalizeProvider)
|
||||
.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
var ordered = configured
|
||||
.Select(NormalizeProvider)
|
||||
.Where(providerId => !disabled.Contains(providerId))
|
||||
.Where(providerId => registry.FindByCapability(capability, includeNonOperational: true)
|
||||
.Any(provider => provider.Id.Equals(providerId, StringComparison.Ordinal)))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
ordered.AddRange(registry.FindByCapability(capability, includeNonOperational: true)
|
||||
.Select(provider => provider.Id)
|
||||
.Where(providerId => !ordered.Contains(providerId, StringComparer.Ordinal))
|
||||
ordered.AddRange(availableProviders
|
||||
.Where(providerId => !disabled.Contains(providerId) &&
|
||||
!ordered.Contains(providerId, StringComparer.Ordinal))
|
||||
.OrderBy(providerId => providerId, StringComparer.Ordinal));
|
||||
return ordered;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ public sealed class DefaultTenantRuntimeSettingsProjector : BackgroundService
|
||||
private readonly ScrobblingSettings _scrobbling;
|
||||
private readonly JellyfinSettings _jellyfin;
|
||||
private readonly SubsonicSettings _subsonic;
|
||||
private readonly TrackMatchPolicy _matching;
|
||||
private readonly ILogger<DefaultTenantRuntimeSettingsProjector> _logger;
|
||||
private readonly string? _bootstrapAppleBaseUrl;
|
||||
private readonly SemaphoreSlim _refresh = new(0, 1);
|
||||
@@ -34,7 +33,6 @@ public sealed class DefaultTenantRuntimeSettingsProjector : BackgroundService
|
||||
IOptions<SpotifyApiSettings> spotifyApi, IOptions<SpotifyImportSettings> spotifyImport,
|
||||
IOptions<MusicBrainzSettings> musicBrainz, IOptions<ScrobblingSettings> scrobbling,
|
||||
IOptions<JellyfinSettings> jellyfin, IOptions<SubsonicSettings> subsonic,
|
||||
TrackMatchPolicy matching,
|
||||
ILogger<DefaultTenantRuntimeSettingsProjector> logger)
|
||||
{
|
||||
(_settings, _signal, _configuration, _logger) = (settings, signal, configuration, logger);
|
||||
@@ -44,7 +42,6 @@ public sealed class DefaultTenantRuntimeSettingsProjector : BackgroundService
|
||||
(_spotifyApi, _spotifyImport, _musicBrainz, _scrobbling) =
|
||||
(spotifyApi.Value, spotifyImport.Value, musicBrainz.Value, scrobbling.Value);
|
||||
(_jellyfin, _subsonic) = (jellyfin.Value, subsonic.Value);
|
||||
_matching = matching;
|
||||
}
|
||||
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
@@ -89,17 +86,17 @@ public sealed class DefaultTenantRuntimeSettingsProjector : BackgroundService
|
||||
Apply(setting);
|
||||
|
||||
var audio = values[AudioQualityPolicy.SettingKey];
|
||||
if (audio.Origin == RuntimeSettingOrigin.Durable || _configuration[AudioQualityPolicy.SettingKey] != null)
|
||||
if (audio.Origin != RuntimeSettingOrigin.Durable &&
|
||||
_configuration[AudioQualityPolicy.SettingKey] == null &&
|
||||
LegacyQualityIsDurable(values))
|
||||
{
|
||||
ApplyAudioQuality((string)audio.Value);
|
||||
}
|
||||
else if (LegacyQualityIsDurable(values))
|
||||
{
|
||||
var migrated = AudioQualityPolicy.FromProviderCeilings(_apple.Quality, _deezer.Quality, _qobuz.Quality);
|
||||
var migrated = AudioQualityPolicy.FromProviderCeilings(
|
||||
LegacyQuality(values, "AppleDownload:Quality", _apple.Quality),
|
||||
LegacyQuality(values, "Deezer:Quality", _deezer.Quality),
|
||||
LegacyQuality(values, "Qobuz:Quality", _qobuz.Quality));
|
||||
await _settings.ApplyBatchAsync(_tenantId,
|
||||
[new RuntimeSettingWrite(AudioQualityPolicy.SettingKey, migrated)],
|
||||
"audio-quality-migration", cancellationToken: cancellationToken);
|
||||
ApplyAudioQuality(migrated);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
@@ -113,11 +110,11 @@ public sealed class DefaultTenantRuntimeSettingsProjector : BackgroundService
|
||||
values["Deezer:Quality"].Origin == RuntimeSettingOrigin.Durable ||
|
||||
values["Qobuz:Quality"].Origin == RuntimeSettingOrigin.Durable;
|
||||
|
||||
private void ApplyAudioQuality(string step)
|
||||
{
|
||||
var quality = AudioQualityPolicy.ProviderCeilings(step);
|
||||
(_apple.Quality, _deezer.Quality, _qobuz.Quality) = (quality.Apple, quality.Deezer, quality.Qobuz);
|
||||
}
|
||||
private static string? LegacyQuality(
|
||||
IReadOnlyDictionary<string, EffectiveRuntimeSetting> values,
|
||||
string key,
|
||||
string? bootstrap) =>
|
||||
values[key].Origin == RuntimeSettingOrigin.Durable ? values[key].NormalizedValue : bootstrap;
|
||||
|
||||
private void Apply(EffectiveRuntimeSetting setting)
|
||||
{
|
||||
@@ -132,14 +129,11 @@ public sealed class DefaultTenantRuntimeSettingsProjector : BackgroundService
|
||||
case "Cache:OdesliLookupDays": _cache.OdesliLookupDays = (int)value; break;
|
||||
case "Cache:ProxyImagesDays": _cache.ProxyImagesDays = (int)value; break;
|
||||
case "Cache:TranscodeCacheMinutes": _cache.TranscodeCacheMinutes = (int)value; break;
|
||||
case "Deezer:Quality": _deezer.Quality = (string)value; break;
|
||||
case "Deezer:MinRequestIntervalMs": _deezer.MinRequestIntervalMs = (int)value; break;
|
||||
case "Qobuz:Quality": _qobuz.Quality = (string)value; break;
|
||||
case "Qobuz:MinRequestIntervalMs": _qobuz.MinRequestIntervalMs = (int)value; break;
|
||||
case "AppleDownload:BaseUrl":
|
||||
if (string.IsNullOrWhiteSpace(_bootstrapAppleBaseUrl)) _apple.BaseUrl = (string)value;
|
||||
break;
|
||||
case "AppleDownload:Quality": _apple.Quality = (string)value; break;
|
||||
case "MusicBrainz:Enabled": _musicBrainz.Enabled = (bool)value; break;
|
||||
case "SpotifyApi:Enabled": _spotifyApi.Enabled = (bool)value; break;
|
||||
case "SpotifyApi:CacheDurationMinutes": _spotifyApi.CacheDurationMinutes = (int)value; break;
|
||||
@@ -155,23 +149,13 @@ public sealed class DefaultTenantRuntimeSettingsProjector : BackgroundService
|
||||
case "Scrobbling:LastFm:Enabled": _scrobbling.LastFm.Enabled = (bool)value; break;
|
||||
case "Scrobbling:ListenBrainz:Enabled": _scrobbling.ListenBrainz.Enabled = (bool)value; break;
|
||||
case "Library:EnableExternalPlaylists": SetBoth(item => item.EnableExternalPlaylists = (bool)value, item => item.EnableExternalPlaylists = (bool)value); break;
|
||||
case "Matching:LocalPreferencePercent": _matching.LocalPriorityWindow = (int)value / 100d; break;
|
||||
case "Library:PlaylistsDirectory": SetBoth(item => item.PlaylistsDirectory = (string)value, item => item.PlaylistsDirectory = (string)value); break;
|
||||
case "Library:ExplicitFilter": SetBoth(item => item.ExplicitFilter = Enum.Parse<ExplicitFilter>((string)value), item => item.ExplicitFilter = Enum.Parse<ExplicitFilter>((string)value)); break;
|
||||
case "Library:DownloadMode": SetBoth(item => item.DownloadMode = Enum.Parse<DownloadMode>((string)value), item => item.DownloadMode = Enum.Parse<DownloadMode>((string)value)); break;
|
||||
case "Library:StorageMode": SetBoth(item => item.StorageMode = Enum.Parse<StorageMode>((string)value), item => item.StorageMode = Enum.Parse<StorageMode>((string)value)); break;
|
||||
case "Library:CacheDurationHours": SetBoth(item => item.CacheDurationHours = (int)value, item => item.CacheDurationHours = (int)value); break;
|
||||
case "Providers:MetadataOrder": SetRouting("MULTI_PROVIDER_METADATA_ORDER", setting.NormalizedValue); break;
|
||||
case "Providers:DownloadOrder": SetRouting("MULTI_PROVIDER_DOWNLOAD_ORDER", setting.NormalizedValue); break;
|
||||
case "Providers:StreamingOrder": SetRouting("MULTI_PROVIDER_STREAMING_ORDER", setting.NormalizedValue); break;
|
||||
case "Providers:PlaylistOrder": SetRouting("MULTI_PROVIDER_PLAYLIST_ORDER", setting.NormalizedValue); break;
|
||||
case "Providers:LyricsOrder": SetRouting("MULTI_PROVIDER_LYRICS_ORDER", setting.NormalizedValue); break;
|
||||
case "Providers:EnabledSearch": SetRouting("MULTI_PROVIDER_ENABLED_SEARCH", setting.NormalizedValue); break;
|
||||
case "Providers:EnabledPlaylist": SetRouting("MULTI_PROVIDER_ENABLED_PLAYLIST", setting.NormalizedValue); break;
|
||||
case "Providers:Disabled": SetRouting("MULTI_PROVIDER_DISABLED_PROVIDERS", setting.NormalizedValue); break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetBoth(Action<JellyfinSettings> jellyfin, Action<SubsonicSettings> subsonic) { jellyfin(_jellyfin); subsonic(_subsonic); }
|
||||
private void SetRouting(string key, string value) => _configuration[key] = value;
|
||||
}
|
||||
@@ -82,7 +82,7 @@ public sealed class RuntimeSettingsChangeSignal : IRuntimeSettingsChangeSignal
|
||||
public sealed record RuntimeSettingDefinition(
|
||||
string Key, RuntimeSettingValueType ValueType, string BootstrapKey,
|
||||
int? Minimum = null, int? Maximum = null, IReadOnlySet<string>? Choices = null,
|
||||
bool AllowEmpty = false, int MaximumLength = 500);
|
||||
bool AllowEmpty = false, int MaximumLength = 500, string? DefaultValue = null);
|
||||
|
||||
public static class RuntimeSettingCatalog
|
||||
{
|
||||
@@ -120,11 +120,11 @@ public static class RuntimeSettingCatalog
|
||||
Int("Qobuz:MinRequestIntervalMs", 0, 60000);
|
||||
items.Add(new("AppleDownload:BaseUrl", RuntimeSettingValueType.String, "AppleDownload:BaseUrl", AllowEmpty: true));
|
||||
items.Add(new("AppleDownload:Quality", RuntimeSettingValueType.String, "AppleDownload:Quality", AllowEmpty: true));
|
||||
items.Add(new("Providers:MetadataOrder", RuntimeSettingValueType.StringList, "MULTI_PROVIDER_METADATA_ORDER"));
|
||||
items.Add(new("Providers:DownloadOrder", RuntimeSettingValueType.StringList, "MULTI_PROVIDER_DOWNLOAD_ORDER"));
|
||||
items.Add(new("Providers:StreamingOrder", RuntimeSettingValueType.StringList, "MULTI_PROVIDER_STREAMING_ORDER"));
|
||||
items.Add(new("Providers:PlaylistOrder", RuntimeSettingValueType.StringList, "MULTI_PROVIDER_PLAYLIST_ORDER"));
|
||||
items.Add(new("Providers:LyricsOrder", RuntimeSettingValueType.StringList, "MULTI_PROVIDER_LYRICS_ORDER"));
|
||||
items.AddRange(ProviderOrderPolicyCatalog.Definitions.Select(item => new RuntimeSettingDefinition(
|
||||
item.SettingKey,
|
||||
RuntimeSettingValueType.StringList,
|
||||
item.BootstrapKey,
|
||||
DefaultValue: item.DefaultValue)));
|
||||
items.Add(new("Providers:EnabledSearch", RuntimeSettingValueType.StringList, "MULTI_PROVIDER_ENABLED_SEARCH"));
|
||||
items.Add(new("Providers:EnabledPlaylist", RuntimeSettingValueType.StringList, "MULTI_PROVIDER_ENABLED_PLAYLIST"));
|
||||
items.Add(new("Providers:Disabled", RuntimeSettingValueType.StringList, "MULTI_PROVIDER_DISABLED_PROVIDERS"));
|
||||
@@ -364,7 +364,7 @@ public sealed class DurableRuntimeSettingsService : IDurableRuntimeSettings
|
||||
{ throw new InvalidOperationException($"Runtime setting '{definition.Key}' has an invalid stored value.", ex); }
|
||||
}
|
||||
|
||||
private static string DefaultRaw(RuntimeSettingDefinition definition) => definition.ValueType switch
|
||||
private static string DefaultRaw(RuntimeSettingDefinition definition) => definition.DefaultValue ?? definition.ValueType switch
|
||||
{
|
||||
RuntimeSettingValueType.String when definition.Key == AudioQualityPolicy.SettingKey => AudioQualityPolicy.DefaultStep,
|
||||
RuntimeSettingValueType.Boolean => "false",
|
||||
@@ -387,6 +387,7 @@ public static class DurableRuntimeSettingsRegistration
|
||||
services.AddSingleton<IRuntimeSettingsChangeSignal>(sp => sp.GetRequiredService<RuntimeSettingsChangeSignal>());
|
||||
services.AddSingleton<DurableRuntimeSettingsService>();
|
||||
services.AddSingleton<IDurableRuntimeSettings>(sp => sp.GetRequiredService<DurableRuntimeSettingsService>());
|
||||
services.AddSingleton<IEffectiveProviderPolicyResolver, EffectiveProviderPolicyResolver>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Immutable;
|
||||
using allstarr.Core.Capabilities;
|
||||
|
||||
namespace allstarr.Core.Settings;
|
||||
|
||||
public sealed record ProviderOrderPolicyDefinition(
|
||||
ProviderCapabilityKind Capability,
|
||||
string SettingKey,
|
||||
string BootstrapKey,
|
||||
string DefaultValue);
|
||||
|
||||
public static class ProviderOrderPolicyCatalog
|
||||
{
|
||||
public static ImmutableArray<ProviderOrderPolicyDefinition> Definitions { get; } =
|
||||
[
|
||||
new(ProviderCapabilityKind.Metadata, "Providers:MetadataOrder", "MULTI_PROVIDER_METADATA_ORDER", "apple-download,deezer,qobuz"),
|
||||
new(ProviderCapabilityKind.Download, "Providers:DownloadOrder", "MULTI_PROVIDER_DOWNLOAD_ORDER", "apple-download,deezer,qobuz"),
|
||||
new(ProviderCapabilityKind.Streaming, "Providers:StreamingOrder", "MULTI_PROVIDER_STREAMING_ORDER", "apple-download,deezer,qobuz"),
|
||||
new(ProviderCapabilityKind.Playlist, "Providers:PlaylistOrder", "MULTI_PROVIDER_PLAYLIST_ORDER", "spotify,apple-download,deezer,qobuz"),
|
||||
new(ProviderCapabilityKind.Lyrics, "Providers:LyricsOrder", "MULTI_PROVIDER_LYRICS_ORDER", "spotify,apple-download,lrclib")
|
||||
];
|
||||
|
||||
public static ProviderOrderPolicyDefinition? Find(ProviderCapabilityKind capability) =>
|
||||
Definitions.FirstOrDefault(item => item.Capability == capability);
|
||||
}
|
||||
|
||||
public sealed record EffectiveProviderPolicySnapshot(
|
||||
Guid TenantId,
|
||||
ImmutableDictionary<ProviderCapabilityKind, ImmutableArray<string>> ProviderOrders,
|
||||
ImmutableHashSet<string> DisabledProviders,
|
||||
string AudioQuality,
|
||||
double LocalPreferenceWindow)
|
||||
{
|
||||
public IReadOnlyList<string> GetProviderOrder(ProviderCapabilityKind capability) =>
|
||||
ProviderOrders.TryGetValue(capability, out var order) ? order : [];
|
||||
|
||||
public IReadOnlyList<string> ApplyProviderAvailability(
|
||||
ProviderCapabilityKind capability,
|
||||
IEnumerable<string> availableProviders)
|
||||
{
|
||||
var available = availableProviders
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim().ToLowerInvariant())
|
||||
.Where(item => !DisabledProviders.Contains(item))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var availableSet = available.ToHashSet(StringComparer.Ordinal);
|
||||
return GetProviderOrder(capability)
|
||||
.Where(availableSet.Contains)
|
||||
.Concat(available.Order(StringComparer.Ordinal))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public interface IEffectiveProviderPolicyResolver
|
||||
{
|
||||
Task<EffectiveProviderPolicySnapshot> ResolveAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class EffectiveProviderPolicyResolver(IDurableRuntimeSettings settings)
|
||||
: IEffectiveProviderPolicyResolver
|
||||
{
|
||||
private static readonly string[] Keys =
|
||||
[
|
||||
.. ProviderOrderPolicyCatalog.Definitions.Select(item => item.SettingKey),
|
||||
"Providers:Disabled",
|
||||
AudioQualityPolicy.SettingKey,
|
||||
"Matching:LocalPreferencePercent"
|
||||
];
|
||||
|
||||
public async Task<EffectiveProviderPolicySnapshot> ResolveAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == Guid.Empty)
|
||||
throw new ArgumentException("A tenant is required.", nameof(tenantId));
|
||||
|
||||
var values = await settings.GetManyAsync(tenantId, Keys, cancellationToken);
|
||||
var orders = ProviderOrderPolicyCatalog.Definitions.ToImmutableDictionary(
|
||||
item => item.Capability,
|
||||
item => ((string[])values[item.SettingKey].Value).ToImmutableArray());
|
||||
var disabled = ((string[])values["Providers:Disabled"].Value)
|
||||
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new EffectiveProviderPolicySnapshot(
|
||||
tenantId,
|
||||
orders,
|
||||
disabled,
|
||||
(string)values[AudioQualityPolicy.SettingKey].Value,
|
||||
(int)values["Matching:LocalPreferencePercent"].Value / 100d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace allstarr.Core.Storage;
|
||||
|
||||
public sealed partial class AllstarrDbContext
|
||||
{
|
||||
public DbSet<CanonicalArtistRecord> CanonicalArtists => Set<CanonicalArtistRecord>();
|
||||
public DbSet<CanonicalReleaseGroupRecord> CanonicalReleaseGroups => Set<CanonicalReleaseGroupRecord>();
|
||||
public DbSet<CanonicalReleaseRecord> CanonicalReleases => Set<CanonicalReleaseRecord>();
|
||||
public DbSet<CanonicalReleaseTrackRecord> CanonicalReleaseTracks => Set<CanonicalReleaseTrackRecord>();
|
||||
public DbSet<CanonicalRecordingArtistRecord> CanonicalRecordingArtists => Set<CanonicalRecordingArtistRecord>();
|
||||
public DbSet<CanonicalReleaseGroupArtistRecord> CanonicalReleaseGroupArtists => Set<CanonicalReleaseGroupArtistRecord>();
|
||||
public DbSet<CanonicalCatalogAliasRecord> CanonicalCatalogAliases => Set<CanonicalCatalogAliasRecord>();
|
||||
public DbSet<CatalogFactRecord> CatalogFacts => Set<CatalogFactRecord>();
|
||||
|
||||
private static void ConfigureCanonicalCatalog(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<CanonicalArtistRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("canonical_artists");
|
||||
entity.HasKey(item => item.Id);
|
||||
entity.HasAlternateKey(item => new { item.TenantId, item.Id });
|
||||
entity.Property(item => item.Id).ValueGeneratedNever();
|
||||
entity.Property(item => item.Name).HasMaxLength(500).IsRequired();
|
||||
entity.Property(item => item.SortName).HasMaxLength(500).IsRequired();
|
||||
entity.Property(item => item.Disambiguation).HasMaxLength(500);
|
||||
entity.Property(item => item.MusicBrainzArtistId).HasMaxLength(100);
|
||||
entity.Property(item => item.Revision).IsConcurrencyToken();
|
||||
entity.HasIndex(item => new { item.TenantId, item.MusicBrainzArtistId }).IsUnique();
|
||||
entity.HasIndex(item => new { item.TenantId, item.SortName, item.Id });
|
||||
TenantOwned(entity);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CanonicalReleaseGroupRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("canonical_release_groups");
|
||||
entity.HasKey(item => item.Id);
|
||||
entity.HasAlternateKey(item => new { item.TenantId, item.Id });
|
||||
entity.Property(item => item.Id).ValueGeneratedNever();
|
||||
entity.Property(item => item.Title).HasMaxLength(500).IsRequired();
|
||||
entity.Property(item => item.PrimaryType).HasMaxLength(100);
|
||||
entity.Property(item => item.SecondaryTypesJson).HasColumnType("jsonb").IsRequired();
|
||||
entity.Property(item => item.FirstReleaseDate).HasMaxLength(10);
|
||||
entity.Property(item => item.MusicBrainzReleaseGroupId).HasMaxLength(100);
|
||||
entity.Property(item => item.Revision).IsConcurrencyToken();
|
||||
entity.HasIndex(item => new { item.TenantId, item.MusicBrainzReleaseGroupId }).IsUnique();
|
||||
entity.HasIndex(item => new { item.TenantId, item.Title, item.Id });
|
||||
TenantOwned(entity);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CanonicalReleaseRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("canonical_releases");
|
||||
entity.HasKey(item => item.Id);
|
||||
entity.HasAlternateKey(item => new { item.TenantId, item.Id });
|
||||
entity.Property(item => item.Id).ValueGeneratedNever();
|
||||
entity.Property(item => item.Title).HasMaxLength(500).IsRequired();
|
||||
entity.Property(item => item.Disambiguation).HasMaxLength(500);
|
||||
entity.Property(item => item.Status).HasMaxLength(100);
|
||||
entity.Property(item => item.CountryCode).HasMaxLength(2);
|
||||
entity.Property(item => item.ReleaseDate).HasMaxLength(10);
|
||||
entity.Property(item => item.Barcode).HasMaxLength(100);
|
||||
entity.Property(item => item.MusicBrainzReleaseId).HasMaxLength(100);
|
||||
entity.Property(item => item.Revision).IsConcurrencyToken();
|
||||
entity.HasIndex(item => new { item.TenantId, item.MusicBrainzReleaseId }).IsUnique();
|
||||
entity.HasIndex(item => new { item.TenantId, item.CanonicalReleaseGroupId, item.ReleaseDate });
|
||||
TenantOwned(entity);
|
||||
entity.HasOne<CanonicalReleaseGroupRecord>().WithMany()
|
||||
.HasForeignKey(item => new { item.TenantId, item.CanonicalReleaseGroupId })
|
||||
.HasPrincipalKey(item => new { item.TenantId, item.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CanonicalReleaseTrackRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("canonical_release_tracks", table =>
|
||||
table.HasCheckConstraint("CK_canonical_release_track_position", "\"MediumPosition\" > 0 AND \"TrackPosition\" > 0"));
|
||||
entity.HasKey(item => item.Id);
|
||||
entity.HasAlternateKey(item => new { item.TenantId, item.Id });
|
||||
entity.Property(item => item.Id).ValueGeneratedNever();
|
||||
entity.Property(item => item.Title).HasMaxLength(500).IsRequired();
|
||||
entity.Property(item => item.MusicBrainzTrackId).HasMaxLength(100);
|
||||
entity.Property(item => item.Revision).IsConcurrencyToken();
|
||||
entity.HasIndex(item => new { item.TenantId, item.MusicBrainzTrackId }).IsUnique();
|
||||
entity.HasIndex(item => new
|
||||
{
|
||||
item.TenantId,
|
||||
item.CanonicalReleaseId,
|
||||
item.MediumPosition,
|
||||
item.TrackPosition
|
||||
}).IsUnique();
|
||||
entity.HasIndex(item => new { item.TenantId, item.CanonicalRecordingId });
|
||||
TenantOwned(entity);
|
||||
entity.HasOne<CanonicalReleaseRecord>().WithMany()
|
||||
.HasForeignKey(item => new { item.TenantId, item.CanonicalReleaseId })
|
||||
.HasPrincipalKey(item => new { item.TenantId, item.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne<CanonicalRecordingRecord>().WithMany()
|
||||
.HasForeignKey(item => new { item.TenantId, item.CanonicalRecordingId })
|
||||
.HasPrincipalKey(item => new { item.TenantId, item.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CanonicalRecordingArtistRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("canonical_recording_artists", table =>
|
||||
table.HasCheckConstraint("CK_canonical_recording_artist_position", "\"Position\" >= 0"));
|
||||
entity.HasKey(item => new { item.TenantId, item.CanonicalRecordingId, item.Position });
|
||||
entity.Property(item => item.CreditName).HasMaxLength(500);
|
||||
entity.Property(item => item.JoinPhrase).HasMaxLength(50).IsRequired();
|
||||
entity.HasIndex(item => new { item.TenantId, item.CanonicalArtistId, item.CanonicalRecordingId });
|
||||
entity.HasOne<CanonicalRecordingRecord>().WithMany()
|
||||
.HasForeignKey(item => new { item.TenantId, item.CanonicalRecordingId })
|
||||
.HasPrincipalKey(item => new { item.TenantId, item.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne<CanonicalArtistRecord>().WithMany()
|
||||
.HasForeignKey(item => new { item.TenantId, item.CanonicalArtistId })
|
||||
.HasPrincipalKey(item => new { item.TenantId, item.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CanonicalReleaseGroupArtistRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("canonical_release_group_artists", table =>
|
||||
table.HasCheckConstraint("CK_canonical_release_group_artist_position", "\"Position\" >= 0"));
|
||||
entity.HasKey(item => new { item.TenantId, item.CanonicalReleaseGroupId, item.Position });
|
||||
entity.Property(item => item.CreditName).HasMaxLength(500);
|
||||
entity.Property(item => item.JoinPhrase).HasMaxLength(50).IsRequired();
|
||||
entity.HasIndex(item => new { item.TenantId, item.CanonicalArtistId, item.CanonicalReleaseGroupId });
|
||||
entity.HasOne<CanonicalReleaseGroupRecord>().WithMany()
|
||||
.HasForeignKey(item => new { item.TenantId, item.CanonicalReleaseGroupId })
|
||||
.HasPrincipalKey(item => new { item.TenantId, item.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne<CanonicalArtistRecord>().WithMany()
|
||||
.HasForeignKey(item => new { item.TenantId, item.CanonicalArtistId })
|
||||
.HasPrincipalKey(item => new { item.TenantId, item.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CanonicalCatalogAliasRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("canonical_catalog_aliases", table =>
|
||||
table.HasCheckConstraint("CK_canonical_catalog_alias_hash", "length(\"ExternalIdHash\") = 64"));
|
||||
entity.HasKey(item => item.Id);
|
||||
entity.Property(item => item.Id).ValueGeneratedNever();
|
||||
entity.Property(item => item.EntityKind).HasConversion<string>().HasMaxLength(32);
|
||||
entity.Property(item => item.Namespace).HasMaxLength(100).IsRequired();
|
||||
entity.Property(item => item.ExternalId).HasMaxLength(500).IsRequired();
|
||||
entity.Property(item => item.ExternalIdHash).HasMaxLength(64).IsRequired();
|
||||
entity.HasIndex(item => new { item.TenantId, item.Namespace, item.EntityKind, item.ExternalIdHash }).IsUnique();
|
||||
entity.HasIndex(item => new { item.TenantId, item.EntityKind, item.CanonicalEntityId });
|
||||
TenantOwned(entity);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CatalogFactRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("catalog_facts", table =>
|
||||
{
|
||||
table.HasCheckConstraint("CK_catalog_fact_confidence", "\"Confidence\" >= 0 AND \"Confidence\" <= 1");
|
||||
table.HasCheckConstraint("CK_catalog_fact_payload_hash", "length(\"PayloadSha256\") = 64");
|
||||
});
|
||||
entity.HasKey(item => item.Id);
|
||||
entity.Property(item => item.Id).ValueGeneratedNever();
|
||||
entity.Property(item => item.EntityKind).HasConversion<string>().HasMaxLength(32);
|
||||
entity.Property(item => item.FieldName).HasMaxLength(100).IsRequired();
|
||||
entity.Property(item => item.ValueJson).HasColumnType("jsonb").IsRequired();
|
||||
entity.Property(item => item.SourceId).HasMaxLength(100).IsRequired();
|
||||
entity.Property(item => item.SourceRevision).HasMaxLength(500);
|
||||
entity.Property(item => item.PayloadSha256).HasMaxLength(64).IsRequired();
|
||||
entity.HasIndex(item => new { item.TenantId, item.EntityKind, item.CanonicalEntityId, item.FieldName });
|
||||
entity.HasIndex(item => new { item.TenantId, item.SourceId, item.RefreshAfter });
|
||||
TenantOwned(entity);
|
||||
});
|
||||
}
|
||||
|
||||
private static void TenantOwned<TEntity>(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<TEntity> entity)
|
||||
where TEntity : class
|
||||
{
|
||||
entity.HasOne<TenantRecord>().WithMany().HasForeignKey("TenantId").OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ public sealed partial class AllstarrDbContext(DbContextOptions<AllstarrDbContext
|
||||
ConfigureJobs(modelBuilder);
|
||||
ConfigureProviderHealth(modelBuilder);
|
||||
ConfigureTrackIdentity(modelBuilder);
|
||||
ConfigureCanonicalCatalog(modelBuilder);
|
||||
ConfigureLibraryAndPlaylists(modelBuilder);
|
||||
ConfigureExtensions(modelBuilder);
|
||||
FavoriteModelConfiguration.Configure(modelBuilder);
|
||||
@@ -399,11 +400,14 @@ public sealed partial class AllstarrDbContext(DbContextOptions<AllstarrDbContext
|
||||
entity.HasKey(item => item.Id);
|
||||
entity.HasAlternateKey(item => new { item.TenantId, item.Id });
|
||||
entity.Property(item => item.Id).ValueGeneratedNever();
|
||||
entity.Property(item => item.Title).HasMaxLength(500).IsRequired();
|
||||
entity.Property(item => item.Disambiguation).HasMaxLength(500);
|
||||
entity.Property(item => item.Isrc).HasMaxLength(32);
|
||||
entity.Property(item => item.MusicBrainzRecordingId).HasMaxLength(100);
|
||||
entity.Property(item => item.Revision).IsConcurrencyToken();
|
||||
entity.HasIndex(item => new { item.TenantId, item.Isrc }).IsUnique();
|
||||
entity.HasIndex(item => new { item.TenantId, item.MusicBrainzRecordingId }).IsUnique();
|
||||
entity.HasIndex(item => new { item.TenantId, item.Title, item.Id });
|
||||
entity.HasOne<TenantRecord>().WithMany().HasForeignKey(item => item.TenantId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne<PlatformUserRecord>().WithMany()
|
||||
@@ -591,18 +595,33 @@ public sealed partial class AllstarrDbContext(DbContextOptions<AllstarrDbContext
|
||||
{
|
||||
Playlists.PersistenceGuard.ValidateSafeJson(entry.Entity.DetailsJson, nameof(PlaylistSyncEntryResultRecord.DetailsJson));
|
||||
}
|
||||
foreach (var entry in ChangeTracker.Entries<CanonicalReleaseGroupRecord>()
|
||||
.Where(entry => entry.State is EntityState.Added or EntityState.Modified))
|
||||
{
|
||||
Playlists.PersistenceGuard.ValidateSafeJson(
|
||||
entry.Entity.SecondaryTypesJson,
|
||||
nameof(CanonicalReleaseGroupRecord.SecondaryTypesJson));
|
||||
}
|
||||
foreach (var entry in ChangeTracker.Entries<CatalogFactRecord>()
|
||||
.Where(entry => entry.State is EntityState.Added or EntityState.Modified))
|
||||
{
|
||||
Playlists.PersistenceGuard.ValidateSafeJson(entry.Entity.ValueJson, nameof(CatalogFactRecord.ValueJson));
|
||||
}
|
||||
var changed = ChangeTracker.Entries()
|
||||
.FirstOrDefault(entry =>
|
||||
(entry.State is EntityState.Modified or EntityState.Deleted) &&
|
||||
(entry.Entity is ExternalMetadataSnapshotRecord or
|
||||
PlaylistSourceSnapshotRecord or
|
||||
PlaylistSourceEntryRecord) &&
|
||||
PlaylistSourceEntryRecord or
|
||||
CatalogFactRecord) &&
|
||||
!(entry.State == EntityState.Modified &&
|
||||
entry.Properties.Where(property => property.IsModified).All(property =>
|
||||
entry.Entity is PlaylistSourceSnapshotRecord &&
|
||||
property.Metadata.Name == nameof(PlaylistSourceSnapshotRecord.PublishedAt) ||
|
||||
entry.Entity is PlaylistSourceEntryRecord &&
|
||||
property.Metadata.Name == nameof(PlaylistSourceEntryRecord.PublishedTrackMatchId))));
|
||||
property.Metadata.Name == nameof(PlaylistSourceEntryRecord.PublishedTrackMatchId) ||
|
||||
entry.Entity is CatalogFactRecord &&
|
||||
property.Metadata.Name == nameof(CatalogFactRecord.SupersededAt))));
|
||||
if (changed != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
namespace allstarr.Core.Storage;
|
||||
|
||||
public enum CanonicalCatalogEntityKind
|
||||
{
|
||||
Artist,
|
||||
ReleaseGroup,
|
||||
Release,
|
||||
ReleaseTrack,
|
||||
Recording
|
||||
}
|
||||
|
||||
public sealed class CanonicalArtistRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string SortName { get; set; } = string.Empty;
|
||||
public string? Disambiguation { get; set; }
|
||||
public string? MusicBrainzArtistId { get; set; }
|
||||
public bool IsProvisional { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public long Revision { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CanonicalReleaseGroupRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? PrimaryType { get; set; }
|
||||
public string SecondaryTypesJson { get; set; } = "[]";
|
||||
public string? FirstReleaseDate { get; set; }
|
||||
public string? MusicBrainzReleaseGroupId { get; set; }
|
||||
public bool IsProvisional { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public long Revision { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CanonicalReleaseRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid CanonicalReleaseGroupId { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Disambiguation { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? CountryCode { get; set; }
|
||||
public string? ReleaseDate { get; set; }
|
||||
public string? Barcode { get; set; }
|
||||
public string? MusicBrainzReleaseId { get; set; }
|
||||
public bool IsProvisional { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public long Revision { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CanonicalReleaseTrackRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid CanonicalReleaseId { get; set; }
|
||||
public Guid CanonicalRecordingId { get; set; }
|
||||
public int MediumPosition { get; set; }
|
||||
public int TrackPosition { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public long? DurationMilliseconds { get; set; }
|
||||
public string? MusicBrainzTrackId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public long Revision { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CanonicalRecordingArtistRecord
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid CanonicalRecordingId { get; set; }
|
||||
public Guid CanonicalArtistId { get; set; }
|
||||
public int Position { get; set; }
|
||||
public string? CreditName { get; set; }
|
||||
public string JoinPhrase { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CanonicalReleaseGroupArtistRecord
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid CanonicalReleaseGroupId { get; set; }
|
||||
public Guid CanonicalArtistId { get; set; }
|
||||
public int Position { get; set; }
|
||||
public string? CreditName { get; set; }
|
||||
public string JoinPhrase { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CanonicalCatalogAliasRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public CanonicalCatalogEntityKind EntityKind { get; set; }
|
||||
public Guid CanonicalEntityId { get; set; }
|
||||
public string Namespace { get; set; } = string.Empty;
|
||||
public string ExternalId { get; set; } = string.Empty;
|
||||
public string ExternalIdHash { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset LastSeenAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CatalogFactRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public CanonicalCatalogEntityKind EntityKind { get; set; }
|
||||
public Guid CanonicalEntityId { get; set; }
|
||||
public string FieldName { get; set; } = string.Empty;
|
||||
public string ValueJson { get; set; } = "null";
|
||||
public string SourceId { get; set; } = string.Empty;
|
||||
public string? SourceRevision { get; set; }
|
||||
public string PayloadSha256 { get; set; } = string.Empty;
|
||||
public double Confidence { get; set; }
|
||||
public DateTimeOffset ObservedAt { get; set; }
|
||||
public DateTimeOffset? RefreshAfter { get; set; }
|
||||
public DateTimeOffset? SupersededAt { get; set; }
|
||||
}
|
||||
@@ -279,6 +279,11 @@ public sealed class CanonicalRecordingRecord
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid CreatedByUserId { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Disambiguation { get; set; }
|
||||
public long? DurationMilliseconds { get; set; }
|
||||
public bool? IsExplicit { get; set; }
|
||||
public bool IsProvisional { get; set; }
|
||||
public string? Isrc { get; set; }
|
||||
public string? MusicBrainzRecordingId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace allstarr.Core.Storage.Migrations;
|
||||
|
||||
[DbContext(typeof(AllstarrDbContext))]
|
||||
[Migration("20260914010000_ScopeDownloadedSongMappings")]
|
||||
public sealed class ScopeDownloadedSongMappings : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_downloaded_song_mapping_identity",
|
||||
table: "downloaded_song_mappings");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AudioQuality",
|
||||
table: "downloaded_song_mappings",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LibraryScopeId",
|
||||
table: "downloaded_song_mappings",
|
||||
type: "character varying(300)",
|
||||
maxLength: 300,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ProviderAccountId",
|
||||
table: "downloaded_song_mappings",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ScopeKey",
|
||||
table: "downloaded_song_mappings",
|
||||
type: "character varying(64)",
|
||||
maxLength: 64,
|
||||
nullable: false,
|
||||
defaultValue: "legacy");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "TenantId",
|
||||
table: "downloaded_song_mappings",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_downloaded_song_mapping_identity",
|
||||
table: "downloaded_song_mappings",
|
||||
columns: new[] { "ScopeKey", "ProviderId", "ExternalId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_downloaded_song_mapping_identity",
|
||||
table: "downloaded_song_mappings");
|
||||
|
||||
migrationBuilder.DropColumn(name: "AudioQuality", table: "downloaded_song_mappings");
|
||||
migrationBuilder.DropColumn(name: "LibraryScopeId", table: "downloaded_song_mappings");
|
||||
migrationBuilder.DropColumn(name: "ProviderAccountId", table: "downloaded_song_mappings");
|
||||
migrationBuilder.DropColumn(name: "ScopeKey", table: "downloaded_song_mappings");
|
||||
migrationBuilder.DropColumn(name: "TenantId", table: "downloaded_song_mappings");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_downloaded_song_mapping_identity",
|
||||
table: "downloaded_song_mappings",
|
||||
columns: new[] { "ProviderId", "ExternalId" },
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
+5975
@@ -0,0 +1,5975 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using allstarr.Core.Storage;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace allstarr.Core.Storage.Migrations
|
||||
{
|
||||
[DbContext(typeof(AllstarrDbContext))]
|
||||
[Migration("20260915000028_ExpandCanonicalCatalog")]
|
||||
partial class ExpandCanonicalCatalog
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Downloads.DownloadedSongMappingEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Album")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("AudioQuality")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("DownloadedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("LocalPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<Guid?>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ScopeKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ScopeKey", "ProviderId", "ExternalId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_downloaded_song_mapping_identity");
|
||||
|
||||
b.ToTable("downloaded_song_mappings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Downloads.ProviderDownloadArtifactEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int?>("BitDepth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("Bitrate")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("Channels")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Codec")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Container")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ContentSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("DurableJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("Length")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid?>("ManagedFileId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("PlacedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderArtifactId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("RelativePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("SampleRate")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("VerifiedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("WorkspaceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceRecordId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ManagedFileId");
|
||||
|
||||
b.HasIndex("WorkspaceRecordId", "ProviderArtifactId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_download_artifact_identity");
|
||||
|
||||
b.HasIndex("TenantId", "DurableJobId", "ProviderId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_download_artifact_job_provider");
|
||||
|
||||
b.ToTable("provider_download_artifacts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_download_artifact_length", "\"Length\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_download_artifact_sha", "length(\"ContentSha256\") = 64");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Downloads.ProviderDownloadWorkspaceEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("CompletedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("DurableJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid?>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("WorkspaceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DurableJobId");
|
||||
|
||||
b.HasIndex("WorkspaceId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ProviderAccountId", "ProviderId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId");
|
||||
|
||||
b.HasIndex("TenantId", "DurableJobId", "ProviderId", "ProviderAccountId", "IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_download_workspace_idempotency");
|
||||
|
||||
b.ToTable("provider_download_workspaces", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteActionPolicyRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool?>("AddToVirtualLiked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool?>("AutoDownload")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool?>("EnrichMetadata")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<bool?>("MatchLocalLibrary")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool?>("PlaceManagedFile")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<bool?>("RefreshBackendLibrary")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("TargetCredentialReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("UpdatedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetCredentialReferenceId")
|
||||
.HasDatabaseName("IX_favorite_policy_credential_reference");
|
||||
|
||||
b.HasIndex("TenantId", "UpdatedByUserId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "Scope", "Protocol", "BackendInstanceId", "LibraryScopeId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_favorite_policy_scope");
|
||||
|
||||
b.ToTable("favorite_action_policies", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteActionRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActionType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("AttemptCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long?>("CompletedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("EventId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("LastErrorCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("LastErrorMessage")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Reversible")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EventId", "ActionType")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_favorite_action_type");
|
||||
|
||||
b.HasIndex("EventId", "TenantId", "OwnerUserId")
|
||||
.HasDatabaseName("IX_favorite_action_event");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "State")
|
||||
.HasDatabaseName("IX_favorite_action_owner_state");
|
||||
|
||||
b.ToTable("favorite_actions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteEventRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("BackendPrincipalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<long?>("CompletedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("EventKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("ItemId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<Guid>("JobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LastErrorCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("LastErrorMessage")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("Operation")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PolicySnapshotJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SourceRevision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("TargetCredentialReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EventKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_favorite_event_key");
|
||||
|
||||
b.HasIndex("JobId")
|
||||
.HasDatabaseName("IX_favorite_event_job");
|
||||
|
||||
b.HasIndex("TargetCredentialReferenceId")
|
||||
.HasDatabaseName("IX_favorite_event_credential_reference");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "CreatedAt")
|
||||
.HasDatabaseName("IX_favorite_event_owner_created");
|
||||
|
||||
b.ToTable("favorite_events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteStateRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("IsFavorite")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ItemId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<Guid>("LastEventId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LastEventId", "TenantId", "OwnerUserId")
|
||||
.HasDatabaseName("IX_favorite_state_event");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "ItemId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_favorite_state_owner_target");
|
||||
|
||||
b.ToTable("favorite_states", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.GeneratedSetEntryRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ExplanationJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("GeneratedSetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("IdentityJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double>("Score")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TrackKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GeneratedSetId", "Position")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("GeneratedSetId", "TenantId", "OwnerUserId");
|
||||
|
||||
b.ToTable("generated_set_entries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.GeneratedSetRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("BackendPlaylistId")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LastErrorCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("MaterializationState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long?>("MaterializedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("RunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ScheduleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("TargetCredentialReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TargetRevision")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RunId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ScheduleId")
|
||||
.HasDatabaseName("IX_generated_set_schedule");
|
||||
|
||||
b.HasIndex("TargetCredentialReferenceId")
|
||||
.HasDatabaseName("IX_generated_set_credential_reference");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId");
|
||||
|
||||
b.HasIndex("RunId", "TenantId", "OwnerUserId");
|
||||
|
||||
b.ToTable("generated_sets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.IntelligencePolicyRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AllowedSignalTypesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("EnabledProvidersJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("RetentionDays")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("TargetCredentialReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetCredentialReferenceId")
|
||||
.HasDatabaseName("IX_intelligence_policy_credential_reference");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_intelligence_policy_scope");
|
||||
|
||||
b.ToTable("intelligence_policies", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningEventRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("AlbumArtist")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid?>("CanonicalRecordingId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("ChosenByUser")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ClientClass")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("DeviceClass")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long?>("DurationMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ImportProvenance")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Isrc")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid?>("LibraryTrackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("ListenedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("MusicBrainzEnrichedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<double?>("MusicBrainzEnrichmentConfidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("MusicBrainzEnrichmentState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("MusicBrainzFactsJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("MusicBrainzSourceRevision")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OccurrenceKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("PositionTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ProviderTrackIdentityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderTrackReference")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("RecordingMusicBrainzId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SourceKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long?>("StartedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int?>("TrackNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TrackReference")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderAccountId");
|
||||
|
||||
b.HasIndex("ProviderTrackIdentityId");
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalRecordingId");
|
||||
|
||||
b.HasIndex("TenantId", "LibraryTrackId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "OccurrenceKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_listening_event_occurrence");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "State", "ListenedAt", "Id")
|
||||
.HasDatabaseName("IX_listening_event_scope_history");
|
||||
|
||||
b.ToTable("listening_events", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_listening_event_duration", "\"DurationMilliseconds\" IS NULL OR \"DurationMilliseconds\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_listening_event_musicbrainz_confidence", "\"MusicBrainzEnrichmentConfidence\" IS NULL OR (\"MusicBrainzEnrichmentConfidence\" >= 0 AND \"MusicBrainzEnrichmentConfidence\" <= 1)");
|
||||
|
||||
t.HasCheckConstraint("CK_listening_event_musicbrainz_state", "\"MusicBrainzEnrichmentState\" IN ('NotRequested', 'Pending', 'Resolved', 'Unresolved', 'Failed')");
|
||||
|
||||
t.HasCheckConstraint("CK_listening_event_position", "\"PositionTicks\" IS NULL OR \"PositionTicks\" >= 0");
|
||||
|
||||
t.HasCheckConstraint("CK_listening_event_track_number", "\"TrackNumber\" IS NULL OR \"TrackNumber\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningHistoryImportRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("ApplyGeneration")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long?>("CompletedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ContentSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DisplayFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("DuplicateRows")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Format")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("ImportedRows")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("JobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<long>("NextSequence")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PreviewJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PreviewRevision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long>("ResolvedRows")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UnresolvedRows")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobId")
|
||||
.HasDatabaseName("IX_listening_history_import_job");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "ContentSha256")
|
||||
.HasDatabaseName("IX_listening_history_import_content");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "CreatedAt")
|
||||
.HasDatabaseName("IX_listening_history_import_scope");
|
||||
|
||||
b.ToTable("listening_history_imports", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_listening_history_import_counts", "\"NextSequence\" >= 0 AND \"ImportedRows\" >= 0 AND \"DuplicateRows\" >= 0 AND \"ResolvedRows\" >= 0 AND \"UnresolvedRows\" >= 0");
|
||||
|
||||
t.HasCheckConstraint("CK_listening_history_import_size", "\"SizeBytes\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_listening_history_import_state", "\"State\" IN ('Previewed', 'Pending', 'Running', 'Completed', 'Cancelled', 'Failed', 'Expired')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningIntakeTokenRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<bool>("RelayExternally")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("SecretReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SecretReferenceId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_listening_intake_token_secret");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "CreatedAt")
|
||||
.HasDatabaseName("IX_listening_intake_token_scope");
|
||||
|
||||
b.ToTable("listening_intake_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningProfileRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProfileJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("WindowEnd")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("WindowStart")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "CreatedAt");
|
||||
|
||||
b.ToTable("listening_profiles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningSignalRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<long>("ObservedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SignalKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("SignalType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("SourceJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TrackKeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("TrackReference")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceJobId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "ExpiresAt");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "SignalKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_listening_signal_idempotency")
|
||||
.HasFilter("\"SignalKey\" IS NOT NULL");
|
||||
|
||||
b.ToTable("listening_signals", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationCandidateRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CanonicalRecordingId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ExclusionsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("IdentityJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("RunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Score")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("SignalsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("SourceRevision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TrackKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderAccountId");
|
||||
|
||||
b.HasIndex("RunId", "Position")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalRecordingId");
|
||||
|
||||
b.HasIndex("RunId", "TenantId", "OwnerUserId");
|
||||
|
||||
b.ToTable("recommendation_candidates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationFeedbackRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("CandidateId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("ReasonCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TrackKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CandidateId", "TenantId", "OwnerUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "TrackKey");
|
||||
|
||||
b.ToTable("recommendation_feedback", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationRunRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long?>("CompletedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ErrorCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("JobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<int>("Limit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PolicySnapshotJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("ScheduleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("ScheduledFor")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SeedTrackKeysJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("TargetCredentialReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobId");
|
||||
|
||||
b.HasIndex("TargetCredentialReferenceId")
|
||||
.HasDatabaseName("IX_recommendation_run_credential_reference");
|
||||
|
||||
b.HasIndex("ScheduleId", "ScheduledFor")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_recommendation_run_schedule_occurrence")
|
||||
.HasFilter("\"ScheduleId\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_recommendation_run_idempotency");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "ScheduleId", "ScheduledFor")
|
||||
.HasDatabaseName("IX_recommendation_run_schedule_history");
|
||||
|
||||
b.ToTable("recommendation_runs", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_recommendation_run_schedule_pair", "(\"ScheduleId\" IS NULL AND \"ScheduledFor\" IS NULL) OR (\"ScheduleId\" IS NOT NULL AND \"ScheduledFor\" IS NOT NULL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.ManagedFiles.ManagedFileOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CanonicalPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<string>("ContentSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("FileSystemDeviceId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("FileSystemFileId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long?>("FileSystemLinkCount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsManaged")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long>("Length")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid?>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PlacementMethod")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("ReferenceCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long?>("RemovedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("RootId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ScopeKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<Guid?>("SourceJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TargetRootPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CanonicalPath")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_managed_file_path");
|
||||
|
||||
b.HasIndex("SourceJobId")
|
||||
.HasDatabaseName("IX_managed_file_job");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId")
|
||||
.HasDatabaseName("IX_managed_file_user");
|
||||
|
||||
b.HasIndex("Id", "TenantId", "OwnerUserId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_managed_file_owner_lineage");
|
||||
|
||||
b.HasIndex("RootId", "ContentSha256", "ScopeKey")
|
||||
.HasDatabaseName("IX_managed_file_fingerprint");
|
||||
|
||||
b.ToTable("managed_files", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_managed_files_owned", "\"IsManaged\" = TRUE");
|
||||
|
||||
t.HasCheckConstraint("CK_managed_files_references", "\"ReferenceCount\" >= 0");
|
||||
|
||||
t.HasCheckConstraint("CK_managed_files_sha256", "length(\"ContentSha256\") = 64");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.ManagedFiles.ManagedFileReferenceEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("ManagedFileId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ReferenceKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<long?>("ReleasedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ScopeKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ManagedFileId", "ReferenceKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_managed_file_reference_key");
|
||||
|
||||
b.HasIndex("TenantId", "ManagedFileId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "ReleasedAt")
|
||||
.HasDatabaseName("IX_managed_file_reference_owner");
|
||||
|
||||
b.ToTable("managed_file_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Playback.PlaybackDeliveryCheckpointEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("DetailsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("OccurrenceKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("RequiresReauthentication")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long?>("RetryAfter")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SafeMessage")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("SignalKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TargetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "OccurrenceKey", "Kind")
|
||||
.HasDatabaseName("IX_playback_delivery_occurrence_status");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "SignalKey", "TargetId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_playback_delivery_idempotency");
|
||||
|
||||
b.ToTable("playback_delivery_checkpoints", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_playback_delivery_checkpoint_state", "\"State\" IN ('Delivered', 'Ignored', 'Retrying', 'PermanentFailure')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Routing.ProviderRouteDecisionEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ActorUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CandidateDecisionsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Capability")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("DurableJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("OperationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("RouteKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("SelectedProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("SelectedProviderId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DurableJobId");
|
||||
|
||||
b.HasIndex("SelectedProviderAccountId", "SelectedProviderId");
|
||||
|
||||
b.HasIndex("TenantId", "ActorUserId");
|
||||
|
||||
b.HasIndex("TenantId", "RouteKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_provider_route_decision_key");
|
||||
|
||||
b.HasIndex("TenantId", "CorrelationId", "CreatedAt")
|
||||
.HasDatabaseName("IX_provider_route_decision_correlation");
|
||||
|
||||
b.ToTable("provider_route_decisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Routing.ProviderRouteOutcomeEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("NextProviderId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OutcomeKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ReasonCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("RouteDecisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Sequence")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderAccountId", "ProviderId");
|
||||
|
||||
b.HasIndex("RouteDecisionId", "OutcomeKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_provider_route_outcome_key");
|
||||
|
||||
b.HasIndex("RouteDecisionId", "TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "CreatedAt")
|
||||
.HasDatabaseName("IX_provider_route_outcome_tenant_created");
|
||||
|
||||
b.ToTable("provider_route_outcomes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Settings.TenantRuntimeSettingRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("UpdatedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)");
|
||||
|
||||
b.Property<string>("ValueType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "UpdatedByUserId");
|
||||
|
||||
b.ToTable("tenant_runtime_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.AdminAuthSessionRecord", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ProtectedPayload")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.ToTable("admin_auth_sessions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ApplicationCacheEntryRecord", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("ExpiresAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("PayloadBytes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.HasIndex("ExpiresAt")
|
||||
.HasDatabaseName("IX_application_cache_expires_at");
|
||||
|
||||
b.HasIndex("Category", "UpdatedAt")
|
||||
.HasDatabaseName("IX_application_cache_category_updated");
|
||||
|
||||
b.ToTable("application_cache_entries", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_application_cache_payload_bytes", "\"PayloadBytes\" >= 0 AND \"PayloadBytes\" <= 1048576");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.AuditEventRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid?>("ActorUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DetailsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ActorUserId");
|
||||
|
||||
b.HasIndex("CorrelationId");
|
||||
|
||||
b.HasIndex("TenantId", "CreatedAt", "Id")
|
||||
.HasDatabaseName("IX_audit_event_updates");
|
||||
|
||||
b.ToTable("audit_events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.BackendIdentityRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("BackendType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("PrincipalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("TenantId", "UserId");
|
||||
|
||||
b.HasIndex("BackendType", "BackendInstanceId", "PrincipalId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("backend_identities", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.BackupRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ApplicationVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("ArtifactPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("RestoreStatus")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<long?>("RestoreVerifiedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SchemaVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Sha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StorageProvider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long?>("VerifiedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("backups", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalArtistRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Disambiguation")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsProvisional")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MusicBrainzArtistId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SortName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "MusicBrainzArtistId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "SortName", "Id");
|
||||
|
||||
b.ToTable("canonical_artists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalCatalogAliasRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalEntityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("EntityKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("ExternalIdHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Namespace")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "EntityKind", "CanonicalEntityId");
|
||||
|
||||
b.HasIndex("TenantId", "Namespace", "EntityKind", "ExternalIdHash")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("canonical_catalog_aliases", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_canonical_catalog_alias_hash", "length(\"ExternalIdHash\") = 64");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingArtistRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalRecordingId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("CanonicalArtistId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CreditName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("JoinPhrase")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("TenantId", "CanonicalRecordingId", "Position");
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalArtistId", "CanonicalRecordingId");
|
||||
|
||||
b.ToTable("canonical_recording_artists", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_canonical_recording_artist_position", "\"Position\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Disambiguation")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long?>("DurationMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool?>("IsExplicit")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsProvisional")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isrc")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("MusicBrainzRecordingId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "CreatedByUserId");
|
||||
|
||||
b.HasIndex("TenantId", "Isrc")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "MusicBrainzRecordingId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "Title", "Id");
|
||||
|
||||
b.ToTable("canonical_recordings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupArtistRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalReleaseGroupId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("CanonicalArtistId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CreditName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("JoinPhrase")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("TenantId", "CanonicalReleaseGroupId", "Position");
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalArtistId", "CanonicalReleaseGroupId");
|
||||
|
||||
b.ToTable("canonical_release_group_artists", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_canonical_release_group_artist_position", "\"Position\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("FirstReleaseDate")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<bool>("IsProvisional")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MusicBrainzReleaseGroupId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("PrimaryType")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SecondaryTypesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "MusicBrainzReleaseGroupId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "Title", "Id");
|
||||
|
||||
b.ToTable("canonical_release_groups", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Barcode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("CanonicalReleaseGroupId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Disambiguation")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsProvisional")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MusicBrainzReleaseId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ReleaseDate")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "MusicBrainzReleaseId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalReleaseGroupId", "ReleaseDate");
|
||||
|
||||
b.ToTable("canonical_releases", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseTrackRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalRecordingId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalReleaseId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("DurationMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("MediumPosition")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("MusicBrainzTrackId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("TrackPosition")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id");
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalRecordingId");
|
||||
|
||||
b.HasIndex("TenantId", "MusicBrainzTrackId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalReleaseId", "MediumPosition", "TrackPosition")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("canonical_release_tracks", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_canonical_release_track_position", "\"MediumPosition\" > 0 AND \"TrackPosition\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CatalogFactRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalEntityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Confidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("EntityKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("FieldName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("ObservedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("PayloadSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long?>("RefreshAfter")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("SourceRevision")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long?>("SupersededAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "SourceId", "RefreshAfter");
|
||||
|
||||
b.HasIndex("TenantId", "EntityKind", "CanonicalEntityId", "FieldName");
|
||||
|
||||
b.ToTable("catalog_facts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_catalog_fact_confidence", "\"Confidence\" >= 0 AND \"Confidence\" <= 1");
|
||||
|
||||
t.HasCheckConstraint("CK_catalog_fact_payload_hash", "length(\"PayloadSha256\") = 64");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.DurableJobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AttemptCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("AvailableAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("CancellationRequestedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("CompletedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("DeferralCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("FailureCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("LastErrorCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("LastErrorMessage")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<long?>("LeaseExpiresAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LeaseOwner")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<int>("MaxAttempts")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MaxDeferrals")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PolicySnapshotJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderCapability")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("RequestFingerprint")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ScopeKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long?>("StartedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderAccountId");
|
||||
|
||||
b.HasIndex("Id", "TenantId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_durable_job_tenant_lineage");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId");
|
||||
|
||||
b.HasIndex("Id", "TenantId", "OwnerUserId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_durable_job_owner_lineage");
|
||||
|
||||
b.HasIndex("ScopeKey", "Type", "IdempotencyKey")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("State", "AvailableAt", "Priority");
|
||||
|
||||
b.HasIndex("TenantId", "UpdatedAt", "Id")
|
||||
.HasDatabaseName("IX_durable_job_updates");
|
||||
|
||||
b.ToTable("durable_jobs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ExtensionLogRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("EventCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ExtensionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("ExtensionPackageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Level")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExtensionPackageId");
|
||||
|
||||
b.HasIndex("ExtensionId", "CreatedAt");
|
||||
|
||||
b.ToTable("extension_logs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ExtensionPackageRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("ActivatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ContentSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long?>("DisabledAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("ExtensionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("FailureCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ManifestJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PackagePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<Guid?>("PreviousPackageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("RegistryId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("ReviewedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SdkVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Sha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long>("StagedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PreviousPackageId");
|
||||
|
||||
b.HasIndex("RegistryId");
|
||||
|
||||
b.HasIndex("ExtensionId", "State");
|
||||
|
||||
b.HasIndex("ExtensionId", "Version", "Sha256");
|
||||
|
||||
b.ToTable("extension_packages", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_extension_packages_content_hash", "length(\"ContentSha256\") = 64");
|
||||
|
||||
t.HasCheckConstraint("CK_extension_packages_sha256", "length(\"Sha256\") = 64");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ExtensionPermissionReviewRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Decision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("ExtensionPackageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PermissionKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("PermissionValue")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<bool>("Required")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long?>("ReviewedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("ReviewedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReviewedByUserId");
|
||||
|
||||
b.HasIndex("ExtensionPackageId", "PermissionKind", "PermissionValue")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_extension_permission_review_key");
|
||||
|
||||
b.ToTable("extension_permission_reviews", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ExtensionRegistryRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("RegistryUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RegistryUrl")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("extension_registries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ExternalMetadataSnapshotRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("BackendPrincipalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ExternalIdHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PayloadSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ProviderRevision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid?>("ProviderTrackIdentityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ResourceKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<long>("RetrievedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("SnapshotVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("SourceJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderTrackIdentityId");
|
||||
|
||||
b.HasIndex("SourceJobId");
|
||||
|
||||
b.HasIndex("ProviderAccountId", "ProviderId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId");
|
||||
|
||||
b.HasIndex("TenantId", "ProviderAccountId", "ResourceKind", "ExternalIdHash", "SnapshotVersion")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_external_snapshot_version");
|
||||
|
||||
b.ToTable("external_metadata_snapshots", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_external_snapshots_external_hash", "length(\"ExternalIdHash\") = 64");
|
||||
|
||||
t.HasCheckConstraint("CK_external_snapshots_payload_hash", "length(\"PayloadSha256\") = 64");
|
||||
|
||||
t.HasCheckConstraint("CK_external_snapshots_version", "\"SnapshotVersion\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.JobAttemptRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AttemptNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long?>("CompletedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ErrorCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<Guid>("JobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<long>("StartedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("WorkerId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobId", "AttemptNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("job_attempts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.JobScheduleRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("JobType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("MisfirePolicy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long?>("NextRunAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("OverlapPolicy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PayloadTemplateJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("{}");
|
||||
|
||||
b.Property<string>("RetryPolicyJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TimeZoneId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Enabled", "NextRunAt");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId");
|
||||
|
||||
b.ToTable("job_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.LegacyEnvImportRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ActorUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("AppliedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("AuditEventId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProvenanceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SchemaVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("SourceSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuditEventId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "ActorUserId");
|
||||
|
||||
b.HasIndex("TenantId", "SourceSha256", "SchemaVersion")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("legacy_env_imports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.LibraryTrackRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int?>("AcceptedDecisionVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("AlbumArtist")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<Guid>("BackendIdentityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("BackendItemId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<Guid?>("CanonicalRecordingId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CoverArtReference")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<long?>("DurationMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DurationProvenance")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long?>("DurationRetrievedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<long>("IndexedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Isrc")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("MusicBrainzArtistId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("MusicBrainzRecordingId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("MusicBrainzReleaseId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("ProviderIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("SourceModifiedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BackendIdentityId");
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalRecordingId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "Isrc")
|
||||
.HasDatabaseName("IX_library_track_scoped_isrc");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "MusicBrainzRecordingId")
|
||||
.HasDatabaseName("IX_library_track_scoped_musicbrainz");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "BackendInstanceId", "BackendItemId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_library_track_backend_item");
|
||||
|
||||
b.ToTable("library_tracks", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_library_tracks_decision_version", "\"AcceptedDecisionVersion\" IS NULL OR \"AcceptedDecisionVersion\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_library_tracks_duration", "\"DurationMilliseconds\" IS NULL OR \"DurationMilliseconds\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_library_tracks_stable_artwork", "\"CoverArtReference\" IS NULL OR \"CoverArtReference\" NOT LIKE '%://%'");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ManualLyricsMappingRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("DurationSeconds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("IdentityHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("LyricsId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IdentityHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UpdatedAt");
|
||||
|
||||
b.ToTable("manual_lyrics_mappings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ManualTrackOverrideRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Decision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("DecisionVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("ExternalSnapshotId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid?>("LibraryTrackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MatcherVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "ExternalSnapshotId");
|
||||
|
||||
b.HasIndex("TenantId", "LibraryTrackId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "ExternalSnapshotId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_manual_track_override_active")
|
||||
.HasFilter("\"RevokedAt\" IS NULL");
|
||||
|
||||
b.ToTable("manual_track_overrides", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_manual_overrides_shape", "(\"Decision\" = 'Pin' AND \"LibraryTrackId\" IS NOT NULL) OR \"Decision\" = 'Reject'");
|
||||
|
||||
t.HasCheckConstraint("CK_manual_overrides_version", "\"DecisionVersion\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.MetadataEnrichmentApplicationRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ArtifactContentSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ErrorCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("LineageJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ManagedArtifactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("PlanId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SafeErrorMessage")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LineageJobId")
|
||||
.HasDatabaseName("IX_enrichment_application_job");
|
||||
|
||||
b.HasIndex("PlanId", "TenantId", "OwnerUserId", "ManagedArtifactId", "LineageJobId")
|
||||
.HasDatabaseName("IX_enrichment_application_plan");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "PlanId", "ManagedArtifactId", "ArtifactContentSha256")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_enrichment_application_hash");
|
||||
|
||||
b.ToTable("metadata_enrichment_applications", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_enrichment_applications_sha256", "length(\"ArtifactContentSha256\") = 64");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.MetadataEnrichmentPlanRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DecisionsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Fingerprint")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("LineageJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ManagedArtifactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PathValuesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PlanVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SourceRevisionsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasAlternateKey("Id", "TenantId", "OwnerUserId", "ManagedArtifactId", "LineageJobId")
|
||||
.HasName("AK_enrichment_plan_scope");
|
||||
|
||||
b.HasIndex("LineageJobId")
|
||||
.HasDatabaseName("IX_enrichment_plan_job");
|
||||
|
||||
b.HasIndex("ManagedArtifactId")
|
||||
.HasDatabaseName("IX_enrichment_plan_file");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "ManagedArtifactId", "Fingerprint")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_enrichment_plan_fingerprint");
|
||||
|
||||
b.ToTable("metadata_enrichment_plans", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_enrichment_plans_fingerprint", "length(\"Fingerprint\") = 64");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.OnboardingStateRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("CompletedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("CompletedStepsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CompletionSource")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("ReopenedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SchemaVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("onboarding_states", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.OutboxMessageRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AttemptCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("AvailableAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("DeliveredAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("FailedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LastErrorCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("LastErrorMessage")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<long?>("LeaseExpiresAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LeaseOwner")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("MaxAttempts")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("PayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("State", "AvailableAt");
|
||||
|
||||
b.HasIndex("TenantId", "UpdatedAt", "Id")
|
||||
.HasDatabaseName("IX_outbox_updates");
|
||||
|
||||
b.ToTable("outbox_messages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlatformUserRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistLinkRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("ImportMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasDefaultValue("Linked");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("MaterializationMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<bool>("MirrorStaleEntries")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PolicyVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("PreserveManualEntries")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ProjectionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasDefaultValue("Resolved");
|
||||
|
||||
b.Property<Guid>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("RuleVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ScheduleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("SourcePlaylistId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("SourcePlaylistIdHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("SourceProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("SyncArtwork")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("SyncDescription")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("SyncName")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("TargetBackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid?>("TargetCredentialReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TargetPlaylistId")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("TargetProtocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TrackRetention")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasDefaultValue("OnDemand");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderAccountId", "SourceProviderId");
|
||||
|
||||
b.HasIndex("TenantId", "ScheduleId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "SourceProviderId", "ProviderAccountId", "SourcePlaylistIdHash", "TargetProtocol", "TargetBackendInstanceId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_playlist_link_source_target");
|
||||
|
||||
b.ToTable("playlist_links", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_playlist_links_import_mode", "\"ImportMode\" IN ('Linked', 'OneTime')");
|
||||
|
||||
t.HasCheckConstraint("CK_playlist_links_one_time_schedule", "\"ImportMode\" <> 'OneTime' OR \"ScheduleId\" IS NULL");
|
||||
|
||||
t.HasCheckConstraint("CK_playlist_links_source_hash", "length(\"SourcePlaylistIdHash\") = 64");
|
||||
|
||||
t.HasCheckConstraint("CK_playlist_links_track_retention", "\"TrackRetention\" IN ('OnDemand', 'KeepAll')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSourceEntryRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ExternalMetadataSnapshotId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("PlaylistSourceSnapshotId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("PublishedTrackMatchId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("SourceEntryIdHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("SourcePosition")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "ExternalMetadataSnapshotId");
|
||||
|
||||
b.HasIndex("TenantId", "PublishedTrackMatchId");
|
||||
|
||||
b.HasIndex("TenantId", "PlaylistSourceSnapshotId", "SourcePosition")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_playlist_source_entry_position");
|
||||
|
||||
b.ToTable("playlist_source_entries", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_playlist_source_entry_hash", "length(\"SourceEntryIdHash\") = 64");
|
||||
|
||||
t.HasCheckConstraint("CK_playlist_source_entry_position", "\"SourcePosition\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSourceSnapshotRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ArtworkReferenceKey")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("ETag")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PayloadSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("PlaylistLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderRevision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<long?>("PublishedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("RetrievedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("SnapshotVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("SourceJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderAccountId");
|
||||
|
||||
b.HasIndex("SourceJobId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId");
|
||||
|
||||
b.HasIndex("TenantId", "PlaylistLinkId", "SnapshotVersion")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_playlist_snapshot_version");
|
||||
|
||||
b.HasIndex("TenantId", "RetrievedAt", "Id")
|
||||
.HasDatabaseName("IX_playlist_snapshot_updates");
|
||||
|
||||
b.HasIndex("TenantId", "PlaylistLinkId", "PublishedAt", "SnapshotVersion")
|
||||
.HasDatabaseName("IX_playlist_snapshot_published");
|
||||
|
||||
b.ToTable("playlist_source_snapshots", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_playlist_snapshots_payload_hash", "length(\"PayloadSha256\") = 64");
|
||||
|
||||
t.HasCheckConstraint("CK_playlist_snapshots_stable_artwork", "\"ArtworkReferenceKey\" IS NULL OR \"ArtworkReferenceKey\" NOT LIKE '%://%'");
|
||||
|
||||
t.HasCheckConstraint("CK_playlist_snapshots_version", "\"SnapshotVersion\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSyncEntryResultRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("DetailsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("LibraryTrackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("OutcomeCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("PlaylistSourceEntryId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("PlaylistSyncRunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("SourcePosition")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("TargetPosition")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("TrackMatchId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "LibraryTrackId");
|
||||
|
||||
b.HasIndex("TenantId", "PlaylistSourceEntryId");
|
||||
|
||||
b.HasIndex("TenantId", "TrackMatchId");
|
||||
|
||||
b.HasIndex("TenantId", "PlaylistSyncRunId", "SourcePosition")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_playlist_result_run_position");
|
||||
|
||||
b.ToTable("playlist_sync_entry_results", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_playlist_result_positions", "\"SourcePosition\" >= 0 AND (\"TargetPosition\" IS NULL OR \"TargetPosition\" >= 0)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSyncRunRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("CompletedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ConflictCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Generation")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid?>("JobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MaterializationMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("PlannedTargetDurationMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("PlannedTargetTrackCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("PlaylistLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("PlaylistSourceSnapshotId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("RuleVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ScheduleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("StartedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TargetRevisionAfter")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("TargetRevisionBefore")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("VerificationCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long?>("VerifiedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("VerifiedTargetDurationMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("VerifiedTargetTrackCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId");
|
||||
|
||||
b.HasIndex("TenantId", "PlaylistSourceSnapshotId");
|
||||
|
||||
b.HasIndex("TenantId", "ScheduleId");
|
||||
|
||||
b.HasIndex("TenantId", "PlaylistLinkId", "IdempotencyKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("playlist_sync_runs", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_playlist_sync_generation", "\"Generation\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_playlist_sync_verification_counts", "(\"PlannedTargetTrackCount\" IS NULL OR \"PlannedTargetTrackCount\" >= 0) AND (\"VerifiedTargetTrackCount\" IS NULL OR \"VerifiedTargetTrackCount\" >= 0)");
|
||||
|
||||
t.HasCheckConstraint("CK_playlist_sync_verification_durations", "(\"PlannedTargetDurationMilliseconds\" IS NULL OR \"PlannedTargetDurationMilliseconds\" >= 0) AND (\"VerifiedTargetDurationMilliseconds\" IS NULL OR \"VerifiedTargetDurationMilliseconds\" >= 0)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistTargetMembershipRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("CreatedBySyncRunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("LastKnownPosition")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("LibraryTrackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("PlaylistLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("TargetEntryId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "CreatedBySyncRunId");
|
||||
|
||||
b.HasIndex("TenantId", "LibraryTrackId");
|
||||
|
||||
b.HasIndex("TenantId", "PlaylistLinkId", "TargetEntryId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_playlist_membership_target_entry");
|
||||
|
||||
b.HasIndex("TenantId", "PlaylistLinkId", "LibraryTrackId", "Active")
|
||||
.HasDatabaseName("IX_playlist_membership_track_active");
|
||||
|
||||
b.ToTable("playlist_target_memberships", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_playlist_membership_position", "\"LastKnownPosition\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ProviderAccountRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("CreatedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid?>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("SecretReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedByUserId");
|
||||
|
||||
b.HasIndex("SecretReferenceId");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId");
|
||||
|
||||
b.HasIndex("ProviderId", "TenantId", "OwnerUserId");
|
||||
|
||||
b.ToTable("provider_accounts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_provider_accounts_scope_shape", "(\"Scope\" = 'Global' AND \"TenantId\" IS NULL AND \"OwnerUserId\" IS NULL AND \"LibraryScopeId\" IS NULL) OR (\"Scope\" = 'User' AND \"TenantId\" IS NOT NULL AND \"OwnerUserId\" IS NOT NULL AND \"LibraryScopeId\" IS NULL) OR (\"Scope\" = 'Library' AND \"TenantId\" IS NOT NULL AND \"OwnerUserId\" IS NULL AND \"LibraryScopeId\" IS NOT NULL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ProviderCircuitRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Capability")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long?>("OpenedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long?>("RetryAfter")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderAccountId", "Capability")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("provider_circuits", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ProviderHealthRollupRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Capability")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("FailureCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("LastFailureCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("LastState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<long?>("P50LatencyMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("P95LatencyMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("SampleCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SuccessCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double>("SuccessRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("WindowEnd")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("WindowStart")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WindowEnd")
|
||||
.HasDatabaseName("IX_provider_health_rollup_window_end");
|
||||
|
||||
b.HasIndex("ProviderAccountId", "Capability", "WindowStart")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_provider_health_rollup_account_capability_window");
|
||||
|
||||
b.ToTable("provider_health_rollups", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ProviderHealthSampleRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Capability")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("FailureCode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long?>("LatencyMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("ObservedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderAccountId", "Capability", "ObservedAt")
|
||||
.HasDatabaseName("IX_provider_health_account_capability_observed");
|
||||
|
||||
b.HasIndex("TenantId", "ObservedAt", "Id")
|
||||
.HasDatabaseName("IX_provider_health_updates");
|
||||
|
||||
b.ToTable("provider_health_samples", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ProviderTrackIdentityRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalRecordingId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CatalogNamespace")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("DecisionVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("ExternalIdHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ResourceKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Verification")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("VerificationMethod")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<long>("VerifiedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderAccountId", "ProviderId");
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalRecordingId");
|
||||
|
||||
b.HasIndex("TenantId", "ProviderId", "ResourceKind", "CatalogNamespace", "ExternalIdHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_provider_track_identity_catalog_exact")
|
||||
.HasFilter("\"Scope\" = 'Catalog'");
|
||||
|
||||
b.HasIndex("TenantId", "ProviderId", "ResourceKind", "CatalogNamespace", "ProviderAccountId", "ExternalIdHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_provider_track_identity_account_exact")
|
||||
.HasFilter("\"Scope\" = 'Account'");
|
||||
|
||||
b.ToTable("provider_track_identities", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_provider_track_identities_decision_version", "\"DecisionVersion\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_provider_track_identities_external_hash", "length(\"ExternalIdHash\") = 64");
|
||||
|
||||
t.HasCheckConstraint("CK_provider_track_identities_scope_shape", "(\"Scope\" = 'Catalog' AND \"ProviderAccountId\" IS NULL) OR (\"Scope\" = 'Account' AND \"ProviderAccountId\" IS NOT NULL)");
|
||||
|
||||
t.HasCheckConstraint("CK_provider_track_identities_track_only", "\"ResourceKind\" = 'Track'");
|
||||
|
||||
t.HasCheckConstraint("CK_provider_track_identities_verification", "\"Verification\" IN ('Verified', 'Pinned')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.SecretReferenceRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("ActiveVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("BackendIdentityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Purpose")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BackendIdentityId");
|
||||
|
||||
b.HasIndex("TenantId", "Purpose");
|
||||
|
||||
b.ToTable("secret_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.SecretVersionRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<byte[]>("AuthenticationTag")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<byte[]>("Ciphertext")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("KeyId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<byte[]>("Nonce")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<long?>("RetiredAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("SecretReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SecretReferenceId", "Version")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("secret_versions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.TenantRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("tenants", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.TrackMatchRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CandidateResultsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("CanonicalRecordingId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Confidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("DecidedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("DecisionVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("ExternalSnapshotId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("LibraryIndexRevision")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid?>("LibraryTrackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MatcherVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PolicyVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ReasonsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("SourceSnapshotVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Threshold")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("WarningsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalRecordingId");
|
||||
|
||||
b.HasIndex("TenantId", "ExternalSnapshotId");
|
||||
|
||||
b.HasIndex("TenantId", "LibraryTrackId");
|
||||
|
||||
b.HasIndex("TenantId", "DecidedAt", "Id")
|
||||
.HasDatabaseName("IX_track_match_updates");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "LibraryScopeId", "ExternalSnapshotId", "DecisionVersion")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_track_match_scoped_decision");
|
||||
|
||||
b.ToTable("track_matches", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_track_matches_confidence", "\"Confidence\" >= 0 AND \"Confidence\" <= 1 AND \"Threshold\" >= 0 AND \"Threshold\" <= 1");
|
||||
|
||||
t.HasCheckConstraint("CK_track_matches_selected_shape", "(\"State\" IN ('Accepted', 'Suggested') AND (\"LibraryTrackId\" IS NOT NULL OR \"CanonicalRecordingId\" IS NOT NULL)) OR (\"State\" = 'Pinned' AND \"LibraryTrackId\" IS NOT NULL) OR (\"State\" IN ('Unresolved', 'Rejected', 'Ambiguous') AND \"LibraryTrackId\" IS NULL)");
|
||||
|
||||
t.HasCheckConstraint("CK_track_matches_version", "\"DecisionVersion\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Downloads.ProviderDownloadArtifactEntity", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.ManagedFiles.ManagedFileOwnershipEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ManagedFileId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Downloads.ProviderDownloadWorkspaceEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkspaceRecordId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Downloads.ProviderDownloadWorkspaceEntity", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("DurableJobId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_download_workspace_job");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId", "ProviderId")
|
||||
.HasPrincipalKey("Id", "ProviderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_download_workspace_account");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_download_workspace_user");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteActionPolicyRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "UpdatedByUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteActionRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Favorites.FavoriteEventRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("EventId", "TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("Id", "TenantId", "OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_favorite_action_event");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteEventRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("JobId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Favorites.FavoriteStateRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Favorites.FavoriteEventRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("LastEventId", "TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("Id", "TenantId", "OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_favorite_state_event");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.GeneratedSetEntryRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Intelligence.GeneratedSetRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GeneratedSetId", "TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("Id", "TenantId", "OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.GeneratedSetRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.JobScheduleRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ScheduleId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Intelligence.RecommendationRunRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RunId", "TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("Id", "TenantId", "OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.IntelligencePolicyRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningEventRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_listening_event_provider_account");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ProviderTrackIdentityRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderTrackIdentityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_listening_event_provider_identity");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalRecordingId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_listening_event_canonical_recording");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.LibraryTrackRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "LibraryTrackId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_listening_event_library_track");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningHistoryImportRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningIntakeTokenRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.SecretReferenceRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SecretReferenceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_listening_intake_token_secret");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningProfileRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningSignalRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SourceJobId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_listening_signal_job");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationCandidateRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalRecordingId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Intelligence.RecommendationRunRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RunId", "TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("Id", "TenantId", "OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationFeedbackRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Intelligence.RecommendationCandidateRecord", null)
|
||||
.WithOne()
|
||||
.HasForeignKey("allstarr.Core.Intelligence.RecommendationFeedbackRecord", "CandidateId", "TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("allstarr.Core.Intelligence.RecommendationCandidateRecord", "Id", "TenantId", "OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.RecommendationRunRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("JobId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.JobScheduleRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ScheduleId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.ManagedFiles.ManagedFileOwnershipEntity", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SourceJobId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_managed_file_tenant_job");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_managed_file_tenant_user");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.ManagedFiles.ManagedFileReferenceEntity", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.ManagedFiles.ManagedFileOwnershipEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ManagedFileId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_managed_file_reference_tenant_file");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_managed_file_reference_tenant_user");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Playback.PlaybackDeliveryCheckpointEntity", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Routing.ProviderRouteDecisionEntity", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("DurableJobId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_provider_route_decision_job");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SelectedProviderAccountId", "SelectedProviderId")
|
||||
.HasPrincipalKey("Id", "ProviderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_provider_route_decision_account");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ActorUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_provider_route_decision_actor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Routing.ProviderRouteOutcomeEntity", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId", "ProviderId")
|
||||
.HasPrincipalKey("Id", "ProviderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_provider_route_outcome_account");
|
||||
|
||||
b.HasOne("allstarr.Core.Routing.ProviderRouteDecisionEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RouteDecisionId", "TenantId")
|
||||
.HasPrincipalKey("Id", "TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_provider_route_outcome_decision");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Settings.TenantRuntimeSettingRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "UpdatedByUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.AuditEventRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ActorUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.BackendIdentityRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalArtistRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalCatalogAliasRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingArtistRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalArtistRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalArtistId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalRecordingId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CreatedByUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupArtistRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalArtistRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalArtistId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalReleaseGroupRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalReleaseGroupId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalReleaseGroupRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalReleaseGroupId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseTrackRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalRecordingId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalReleaseRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalReleaseId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CatalogFactRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.DurableJobRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_durable_job_tenant_owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ExtensionLogRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ExtensionPackageRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ExtensionPackageId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ExtensionPackageRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ExtensionPackageRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("PreviousPackageId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ExtensionRegistryRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RegistryId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ExtensionPermissionReviewRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ExtensionPackageRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ExtensionPackageId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_extension_permission_review_package");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ReviewedByUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ExternalMetadataSnapshotRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderTrackIdentityRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderTrackIdentityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_external_snapshot_provider_identity");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SourceJobId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId", "ProviderId")
|
||||
.HasPrincipalKey("Id", "ProviderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_external_snapshot_provider_account");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_ExternalMetadataSnapshot_PlatformUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.JobAttemptRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("JobId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.JobScheduleRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_JobSchedule_PlatformUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.LegacyEnvImportRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.AuditEventRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AuditEventId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ActorUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.LibraryTrackRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.BackendIdentityRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BackendIdentityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalRecordingId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_library_track_canonical_recording");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_LibraryTrack_PlatformUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ManualTrackOverrideRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ExternalMetadataSnapshotRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ExternalSnapshotId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_ManualTrackOverride_ExternalMetadataSnapshot");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.LibraryTrackRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "LibraryTrackId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_ManualTrackOverride_LibraryTrack");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_ManualTrackOverride_PlatformUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.MetadataEnrichmentApplicationRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("LineageJobId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.MetadataEnrichmentPlanRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("PlanId", "TenantId", "OwnerUserId", "ManagedArtifactId", "LineageJobId")
|
||||
.HasPrincipalKey("Id", "TenantId", "OwnerUserId", "ManagedArtifactId", "LineageJobId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_enrichment_application_plan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.MetadataEnrichmentPlanRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("LineageJobId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.ManagedFiles.ManagedFileOwnershipEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ManagedArtifactId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.OnboardingStateRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "UserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.OutboxMessageRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlatformUserRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistLinkRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId", "SourceProviderId")
|
||||
.HasPrincipalKey("Id", "ProviderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_playlist_link_provider_account");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistLink_PlatformUser");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.JobScheduleRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ScheduleId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_PlaylistLink_JobSchedule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSourceEntryRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ExternalMetadataSnapshotRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ExternalMetadataSnapshotId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistSourceEntry_ExternalMetadataSnapshot");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlaylistSourceSnapshotRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "PlaylistSourceSnapshotId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistSourceEntry_PlaylistSourceSnapshot");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TrackMatchRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "PublishedTrackMatchId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_PlaylistSourceEntry_TrackMatch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSourceSnapshotRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_playlist_snapshot_provider_account");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SourceJobId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistSourceSnapshot_PlatformUser");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlaylistLinkRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "PlaylistLinkId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistSourceSnapshot_PlaylistLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSyncEntryResultRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.LibraryTrackRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "LibraryTrackId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_PlaylistSyncEntryResult_LibraryTrack");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlaylistSourceEntryRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "PlaylistSourceEntryId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistSyncEntryResult_PlaylistSourceEntry");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlaylistSyncRunRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "PlaylistSyncRunId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistSyncEntryResult_PlaylistSyncRun");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TrackMatchRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "TrackMatchId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_PlaylistSyncEntryResult_TrackMatch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistSyncRunRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.DurableJobRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("JobId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistSyncRun_PlatformUser");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlaylistLinkRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "PlaylistLinkId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistSyncRun_PlaylistLink");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlaylistSourceSnapshotRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "PlaylistSourceSnapshotId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistSyncRun_PlaylistSourceSnapshot");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.JobScheduleRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ScheduleId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_PlaylistSyncRun_JobSchedule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.PlaylistTargetMembershipRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.PlaylistSyncRunRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CreatedBySyncRunId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistTargetMembership_PlaylistSyncRun");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.LibraryTrackRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "LibraryTrackId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistTargetMembership_LibraryTrack");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlaylistLinkRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "PlaylistLinkId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_PlaylistTargetMembership_PlaylistLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ProviderAccountRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedByUserId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("FK_provider_account_creator");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.SecretReferenceRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SecretReferenceId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_provider_account_tenant_owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ProviderCircuitRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ProviderHealthRollupRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ProviderHealthSampleRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.ProviderTrackIdentityRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId", "ProviderId")
|
||||
.HasPrincipalKey("Id", "ProviderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_track_identity_provider_account");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalRecordingId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_track_identity_canonical_recording");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.SecretReferenceRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.BackendIdentityRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BackendIdentityId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.SecretVersionRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.SecretReferenceRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SecretReferenceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.TrackMatchRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalRecordingId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_TrackMatch_Canonicaling");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ExternalMetadataSnapshotRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ExternalSnapshotId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_TrackMatch_ExternalMetadataSnapshot");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.LibraryTrackRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "LibraryTrackId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_TrackMatch_LibraryTrack");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("FK_TrackMatch_PlatformUser");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace allstarr.Core.Storage.Migrations;
|
||||
|
||||
public partial class ExpandCanonicalCatalog : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>("Title", "canonical_recordings", "character varying(500)", maxLength: 500, nullable: false, defaultValue: "");
|
||||
migrationBuilder.AddColumn<string>("Disambiguation", "canonical_recordings", "character varying(500)", maxLength: 500, nullable: true);
|
||||
migrationBuilder.AddColumn<long>("DurationMilliseconds", "canonical_recordings", "bigint", nullable: true);
|
||||
migrationBuilder.AddColumn<bool>("IsExplicit", "canonical_recordings", "boolean", nullable: true);
|
||||
migrationBuilder.AddColumn<bool>("IsProvisional", "canonical_recordings", "boolean", nullable: false, defaultValue: false);
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_canonical_recordings_TenantId_Title_Id",
|
||||
table: "canonical_recordings",
|
||||
columns: new[] { "TenantId", "Title", "Id" });
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
CREATE TABLE "canonical_artists" (
|
||||
"Id" uuid NOT NULL,
|
||||
"TenantId" uuid NOT NULL,
|
||||
"Name" character varying(500) NOT NULL,
|
||||
"SortName" character varying(500) NOT NULL,
|
||||
"Disambiguation" character varying(500),
|
||||
"MusicBrainzArtistId" character varying(100),
|
||||
"IsProvisional" boolean NOT NULL,
|
||||
"CreatedAt" bigint NOT NULL,
|
||||
"UpdatedAt" bigint NOT NULL,
|
||||
"Revision" bigint NOT NULL,
|
||||
CONSTRAINT "PK_canonical_artists" PRIMARY KEY ("Id"),
|
||||
CONSTRAINT "AK_canonical_artists_TenantId_Id" UNIQUE ("TenantId", "Id"),
|
||||
CONSTRAINT "FK_canonical_artists_tenants_TenantId" FOREIGN KEY ("TenantId") REFERENCES "tenants" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
CREATE UNIQUE INDEX "IX_canonical_artists_TenantId_MusicBrainzArtistId" ON "canonical_artists" ("TenantId", "MusicBrainzArtistId");
|
||||
CREATE INDEX "IX_canonical_artists_TenantId_SortName_Id" ON "canonical_artists" ("TenantId", "SortName", "Id");
|
||||
|
||||
CREATE TABLE "canonical_release_groups" (
|
||||
"Id" uuid NOT NULL,
|
||||
"TenantId" uuid NOT NULL,
|
||||
"Title" character varying(500) NOT NULL,
|
||||
"PrimaryType" character varying(100),
|
||||
"SecondaryTypesJson" jsonb NOT NULL,
|
||||
"FirstReleaseDate" character varying(10),
|
||||
"MusicBrainzReleaseGroupId" character varying(100),
|
||||
"IsProvisional" boolean NOT NULL,
|
||||
"CreatedAt" bigint NOT NULL,
|
||||
"UpdatedAt" bigint NOT NULL,
|
||||
"Revision" bigint NOT NULL,
|
||||
CONSTRAINT "PK_canonical_release_groups" PRIMARY KEY ("Id"),
|
||||
CONSTRAINT "AK_canonical_release_groups_TenantId_Id" UNIQUE ("TenantId", "Id"),
|
||||
CONSTRAINT "FK_canonical_release_groups_tenants_TenantId" FOREIGN KEY ("TenantId") REFERENCES "tenants" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
CREATE UNIQUE INDEX "IX_canonical_release_groups_TenantId_MusicBrainzReleaseGroupId" ON "canonical_release_groups" ("TenantId", "MusicBrainzReleaseGroupId");
|
||||
CREATE INDEX "IX_canonical_release_groups_TenantId_Title_Id" ON "canonical_release_groups" ("TenantId", "Title", "Id");
|
||||
|
||||
CREATE TABLE "canonical_catalog_aliases" (
|
||||
"Id" uuid NOT NULL,
|
||||
"TenantId" uuid NOT NULL,
|
||||
"EntityKind" character varying(32) NOT NULL,
|
||||
"CanonicalEntityId" uuid NOT NULL,
|
||||
"Namespace" character varying(100) NOT NULL,
|
||||
"ExternalId" character varying(500) NOT NULL,
|
||||
"ExternalIdHash" character varying(64) NOT NULL,
|
||||
"CreatedAt" bigint NOT NULL,
|
||||
"LastSeenAt" bigint NOT NULL,
|
||||
CONSTRAINT "PK_canonical_catalog_aliases" PRIMARY KEY ("Id"),
|
||||
CONSTRAINT "CK_canonical_catalog_alias_hash" CHECK (length("ExternalIdHash") = 64),
|
||||
CONSTRAINT "FK_canonical_catalog_aliases_tenants_TenantId" FOREIGN KEY ("TenantId") REFERENCES "tenants" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
CREATE UNIQUE INDEX "IX_canonical_catalog_aliases_TenantId_Namespace_EntityKind_ExternalIdHash" ON "canonical_catalog_aliases" ("TenantId", "Namespace", "EntityKind", "ExternalIdHash");
|
||||
CREATE INDEX "IX_canonical_catalog_aliases_TenantId_EntityKind_CanonicalEntityId" ON "canonical_catalog_aliases" ("TenantId", "EntityKind", "CanonicalEntityId");
|
||||
|
||||
CREATE TABLE "catalog_facts" (
|
||||
"Id" uuid NOT NULL,
|
||||
"TenantId" uuid NOT NULL,
|
||||
"EntityKind" character varying(32) NOT NULL,
|
||||
"CanonicalEntityId" uuid NOT NULL,
|
||||
"FieldName" character varying(100) NOT NULL,
|
||||
"ValueJson" jsonb NOT NULL,
|
||||
"SourceId" character varying(100) NOT NULL,
|
||||
"SourceRevision" character varying(500),
|
||||
"PayloadSha256" character varying(64) NOT NULL,
|
||||
"Confidence" double precision NOT NULL,
|
||||
"ObservedAt" bigint NOT NULL,
|
||||
"RefreshAfter" bigint,
|
||||
"SupersededAt" bigint,
|
||||
CONSTRAINT "PK_catalog_facts" PRIMARY KEY ("Id"),
|
||||
CONSTRAINT "CK_catalog_fact_confidence" CHECK ("Confidence" >= 0 AND "Confidence" <= 1),
|
||||
CONSTRAINT "CK_catalog_fact_payload_hash" CHECK (length("PayloadSha256") = 64),
|
||||
CONSTRAINT "FK_catalog_facts_tenants_TenantId" FOREIGN KEY ("TenantId") REFERENCES "tenants" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX "IX_catalog_facts_TenantId_EntityKind_CanonicalEntityId_FieldName" ON "catalog_facts" ("TenantId", "EntityKind", "CanonicalEntityId", "FieldName");
|
||||
CREATE INDEX "IX_catalog_facts_TenantId_SourceId_RefreshAfter" ON "catalog_facts" ("TenantId", "SourceId", "RefreshAfter");
|
||||
|
||||
CREATE TABLE "canonical_releases" (
|
||||
"Id" uuid NOT NULL,
|
||||
"TenantId" uuid NOT NULL,
|
||||
"CanonicalReleaseGroupId" uuid NOT NULL,
|
||||
"Title" character varying(500) NOT NULL,
|
||||
"Disambiguation" character varying(500),
|
||||
"Status" character varying(100),
|
||||
"CountryCode" character varying(2),
|
||||
"ReleaseDate" character varying(10),
|
||||
"Barcode" character varying(100),
|
||||
"MusicBrainzReleaseId" character varying(100),
|
||||
"IsProvisional" boolean NOT NULL,
|
||||
"CreatedAt" bigint NOT NULL,
|
||||
"UpdatedAt" bigint NOT NULL,
|
||||
"Revision" bigint NOT NULL,
|
||||
CONSTRAINT "PK_canonical_releases" PRIMARY KEY ("Id"),
|
||||
CONSTRAINT "AK_canonical_releases_TenantId_Id" UNIQUE ("TenantId", "Id"),
|
||||
CONSTRAINT "FK_canonical_releases_canonical_release_groups" FOREIGN KEY ("TenantId", "CanonicalReleaseGroupId") REFERENCES "canonical_release_groups" ("TenantId", "Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_canonical_releases_tenants_TenantId" FOREIGN KEY ("TenantId") REFERENCES "tenants" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
CREATE UNIQUE INDEX "IX_canonical_releases_TenantId_MusicBrainzReleaseId" ON "canonical_releases" ("TenantId", "MusicBrainzReleaseId");
|
||||
CREATE INDEX "IX_canonical_releases_TenantId_CanonicalReleaseGroupId_ReleaseDate" ON "canonical_releases" ("TenantId", "CanonicalReleaseGroupId", "ReleaseDate");
|
||||
|
||||
CREATE TABLE "canonical_recording_artists" (
|
||||
"TenantId" uuid NOT NULL,
|
||||
"CanonicalRecordingId" uuid NOT NULL,
|
||||
"CanonicalArtistId" uuid NOT NULL,
|
||||
"Position" integer NOT NULL,
|
||||
"CreditName" character varying(500),
|
||||
"JoinPhrase" character varying(50) NOT NULL,
|
||||
CONSTRAINT "PK_canonical_recording_artists" PRIMARY KEY ("TenantId", "CanonicalRecordingId", "Position"),
|
||||
CONSTRAINT "CK_canonical_recording_artist_position" CHECK ("Position" >= 0),
|
||||
CONSTRAINT "FK_canonical_recording_artists_artist" FOREIGN KEY ("TenantId", "CanonicalArtistId") REFERENCES "canonical_artists" ("TenantId", "Id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_canonical_recording_artists_recording" FOREIGN KEY ("TenantId", "CanonicalRecordingId") REFERENCES "canonical_recordings" ("TenantId", "Id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX "IX_canonical_recording_artists_TenantId_CanonicalArtistId_CanonicalRecordingId" ON "canonical_recording_artists" ("TenantId", "CanonicalArtistId", "CanonicalRecordingId");
|
||||
|
||||
CREATE TABLE "canonical_release_group_artists" (
|
||||
"TenantId" uuid NOT NULL,
|
||||
"CanonicalReleaseGroupId" uuid NOT NULL,
|
||||
"CanonicalArtistId" uuid NOT NULL,
|
||||
"Position" integer NOT NULL,
|
||||
"CreditName" character varying(500),
|
||||
"JoinPhrase" character varying(50) NOT NULL,
|
||||
CONSTRAINT "PK_canonical_release_group_artists" PRIMARY KEY ("TenantId", "CanonicalReleaseGroupId", "Position"),
|
||||
CONSTRAINT "CK_canonical_release_group_artist_position" CHECK ("Position" >= 0),
|
||||
CONSTRAINT "FK_canonical_release_group_artists_artist" FOREIGN KEY ("TenantId", "CanonicalArtistId") REFERENCES "canonical_artists" ("TenantId", "Id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_canonical_release_group_artists_release_group" FOREIGN KEY ("TenantId", "CanonicalReleaseGroupId") REFERENCES "canonical_release_groups" ("TenantId", "Id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX "IX_canonical_release_group_artists_TenantId_CanonicalArtistId_CanonicalReleaseGroupId" ON "canonical_release_group_artists" ("TenantId", "CanonicalArtistId", "CanonicalReleaseGroupId");
|
||||
|
||||
CREATE TABLE "canonical_release_tracks" (
|
||||
"Id" uuid NOT NULL,
|
||||
"TenantId" uuid NOT NULL,
|
||||
"CanonicalReleaseId" uuid NOT NULL,
|
||||
"CanonicalRecordingId" uuid NOT NULL,
|
||||
"MediumPosition" integer NOT NULL,
|
||||
"TrackPosition" integer NOT NULL,
|
||||
"Title" character varying(500) NOT NULL,
|
||||
"DurationMilliseconds" bigint,
|
||||
"MusicBrainzTrackId" character varying(100),
|
||||
"CreatedAt" bigint NOT NULL,
|
||||
"UpdatedAt" bigint NOT NULL,
|
||||
"Revision" bigint NOT NULL,
|
||||
CONSTRAINT "PK_canonical_release_tracks" PRIMARY KEY ("Id"),
|
||||
CONSTRAINT "AK_canonical_release_tracks_TenantId_Id" UNIQUE ("TenantId", "Id"),
|
||||
CONSTRAINT "CK_canonical_release_track_position" CHECK ("MediumPosition" > 0 AND "TrackPosition" > 0),
|
||||
CONSTRAINT "FK_canonical_release_tracks_recording" FOREIGN KEY ("TenantId", "CanonicalRecordingId") REFERENCES "canonical_recordings" ("TenantId", "Id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_canonical_release_tracks_release" FOREIGN KEY ("TenantId", "CanonicalReleaseId") REFERENCES "canonical_releases" ("TenantId", "Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_canonical_release_tracks_tenants_TenantId" FOREIGN KEY ("TenantId") REFERENCES "tenants" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
CREATE UNIQUE INDEX "IX_canonical_release_tracks_TenantId_MusicBrainzTrackId" ON "canonical_release_tracks" ("TenantId", "MusicBrainzTrackId");
|
||||
CREATE UNIQUE INDEX "IX_canonical_release_tracks_TenantId_CanonicalReleaseId_MediumPosition_TrackPosition" ON "canonical_release_tracks" ("TenantId", "CanonicalReleaseId", "MediumPosition", "TrackPosition");
|
||||
CREATE INDEX "IX_canonical_release_tracks_TenantId_CanonicalRecordingId" ON "canonical_release_tracks" ("TenantId", "CanonicalRecordingId");
|
||||
""");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "canonical_recording_artists");
|
||||
migrationBuilder.DropTable(name: "canonical_release_group_artists");
|
||||
migrationBuilder.DropTable(name: "canonical_release_tracks");
|
||||
migrationBuilder.DropTable(name: "canonical_catalog_aliases");
|
||||
migrationBuilder.DropTable(name: "catalog_facts");
|
||||
migrationBuilder.DropTable(name: "canonical_artists");
|
||||
migrationBuilder.DropTable(name: "canonical_releases");
|
||||
migrationBuilder.DropTable(name: "canonical_release_groups");
|
||||
migrationBuilder.DropIndex(name: "IX_canonical_recordings_TenantId_Title_Id", table: "canonical_recordings");
|
||||
migrationBuilder.DropColumn(name: "Title", table: "canonical_recordings");
|
||||
migrationBuilder.DropColumn(name: "Disambiguation", table: "canonical_recordings");
|
||||
migrationBuilder.DropColumn(name: "DurationMilliseconds", table: "canonical_recordings");
|
||||
migrationBuilder.DropColumn(name: "IsExplicit", table: "canonical_recordings");
|
||||
migrationBuilder.DropColumn(name: "IsProvisional", table: "canonical_recordings");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace allstarr.Core.Storage.Migrations;
|
||||
|
||||
[DbContext(typeof(AllstarrDbContext))]
|
||||
[Migration("20260922000030_ProjectNativeTrackAliases")]
|
||||
public sealed class ProjectNativeTrackAliases : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
WITH candidates AS (
|
||||
SELECT
|
||||
track."Id" AS "LibraryTrackId",
|
||||
track."TenantId",
|
||||
track."CanonicalRecordingId",
|
||||
'native:' || encode(sha256(convert_to(
|
||||
track."Protocol" || E'\n' || track."BackendInstanceId",
|
||||
'UTF8')), 'hex') AS "Namespace",
|
||||
track."BackendItemId" AS "ExternalId",
|
||||
encode(sha256(convert_to(track."BackendItemId", 'UTF8')), 'hex') AS "ExternalIdHash",
|
||||
track."IndexedAt",
|
||||
track."UpdatedAt"
|
||||
FROM "library_tracks" track
|
||||
WHERE track."CanonicalRecordingId" IS NOT NULL
|
||||
), consistent AS (
|
||||
SELECT
|
||||
candidate."TenantId",
|
||||
candidate."Namespace",
|
||||
candidate."ExternalIdHash",
|
||||
min(candidate."ExternalId") AS "ExternalId",
|
||||
min(candidate."CanonicalRecordingId"::text)::uuid AS "CanonicalRecordingId",
|
||||
min(candidate."LibraryTrackId"::text)::uuid AS "LibraryTrackId",
|
||||
min(candidate."IndexedAt") AS "CreatedAt",
|
||||
max(candidate."UpdatedAt") AS "LastSeenAt"
|
||||
FROM candidates candidate
|
||||
GROUP BY
|
||||
candidate."TenantId",
|
||||
candidate."Namespace",
|
||||
candidate."ExternalIdHash"
|
||||
HAVING count(DISTINCT candidate."ExternalId") = 1
|
||||
AND count(DISTINCT candidate."CanonicalRecordingId") = 1
|
||||
)
|
||||
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' || 'native' || E'\n' ||
|
||||
consistent."LibraryTrackId"::text,
|
||||
'UTF8')), 'hex'), 1, 32)::uuid,
|
||||
consistent."TenantId",
|
||||
'Recording',
|
||||
consistent."CanonicalRecordingId",
|
||||
consistent."Namespace",
|
||||
consistent."ExternalId",
|
||||
consistent."ExternalIdHash",
|
||||
consistent."CreatedAt",
|
||||
consistent."LastSeenAt"
|
||||
FROM consistent
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "canonical_catalog_aliases" alias
|
||||
WHERE alias."TenantId" = consistent."TenantId"
|
||||
AND alias."Namespace" = consistent."Namespace"
|
||||
AND alias."EntityKind" = 'Recording'
|
||||
AND alias."ExternalIdHash" = consistent."ExternalIdHash");
|
||||
""");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DELETE FROM "canonical_catalog_aliases" alias
|
||||
USING "library_tracks" track
|
||||
WHERE alias."Id" = substr(encode(sha256(convert_to(
|
||||
'canonical-alias-v1' || E'\n' || 'native' || E'\n' || track."Id"::text,
|
||||
'UTF8')), 'hex'), 1, 32)::uuid;
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("AudioQuality")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("DownloadedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -40,11 +43,18 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("LocalPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<Guid?>("ProviderAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -54,6 +64,14 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("ScopeKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
@@ -61,7 +79,7 @@ namespace allstarr.Core.Storage.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderId", "ExternalId")
|
||||
b.HasIndex("ScopeKey", "ProviderId", "ExternalId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_downloaded_song_mapping_identity");
|
||||
|
||||
@@ -817,12 +835,12 @@ namespace allstarr.Core.Storage.Migrations
|
||||
b.Property<long?>("ListenedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<double?>("MusicBrainzEnrichmentConfidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<long?>("MusicBrainzEnrichedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<double?>("MusicBrainzEnrichmentConfidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("MusicBrainzEnrichmentState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
@@ -893,14 +911,14 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int?>("TrackNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TrackReference")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int?>("TrackNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -921,7 +939,7 @@ namespace allstarr.Core.Storage.Migrations
|
||||
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "State", "ListenedAt", "Id")
|
||||
.HasDatabaseName("IX_listening_event_scope_history");
|
||||
|
||||
b.ToTable("listening_events", t =>
|
||||
b.ToTable("listening_events", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_listening_event_duration", "\"DurationMilliseconds\" IS NULL OR \"DurationMilliseconds\" > 0");
|
||||
|
||||
@@ -935,56 +953,6 @@ namespace allstarr.Core.Storage.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningIntakeTokenRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<bool>("RelayExternally")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("SecretReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SecretReferenceId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_listening_intake_token_secret");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "CreatedAt")
|
||||
.HasDatabaseName("IX_listening_intake_token_scope");
|
||||
|
||||
b.ToTable("listening_intake_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningHistoryImportRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1101,6 +1069,56 @@ namespace allstarr.Core.Storage.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningIntakeTokenRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BackendInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("LibraryScopeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<bool>("RelayExternally")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("SecretReferenceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SecretReferenceId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_listening_intake_token_secret");
|
||||
|
||||
b.HasIndex("TenantId", "OwnerUserId", "Protocol", "BackendInstanceId", "LibraryScopeId", "CreatedAt")
|
||||
.HasDatabaseName("IX_listening_intake_token_scope");
|
||||
|
||||
b.ToTable("listening_intake_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningProfileRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1674,7 +1692,7 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_playback_delivery_idempotency");
|
||||
|
||||
b.ToTable("playback_delivery_checkpoints", t =>
|
||||
b.ToTable("playback_delivery_checkpoints", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_playback_delivery_checkpoint_state", "\"State\" IN ('Delivered', 'Ignored', 'Retrying', 'PermanentFailure')");
|
||||
});
|
||||
@@ -2082,6 +2100,138 @@ namespace allstarr.Core.Storage.Migrations
|
||||
b.ToTable("backups", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalArtistRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Disambiguation")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsProvisional")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MusicBrainzArtistId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SortName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "MusicBrainzArtistId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "SortName", "Id");
|
||||
|
||||
b.ToTable("canonical_artists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalCatalogAliasRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalEntityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("EntityKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("ExternalIdHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Namespace")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "EntityKind", "CanonicalEntityId");
|
||||
|
||||
b.HasIndex("TenantId", "Namespace", "EntityKind", "ExternalIdHash")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("canonical_catalog_aliases", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_canonical_catalog_alias_hash", "length(\"ExternalIdHash\") = 64");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingArtistRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalRecordingId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("CanonicalArtistId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CreditName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("JoinPhrase")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("TenantId", "CanonicalRecordingId", "Position");
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalArtistId", "CanonicalRecordingId");
|
||||
|
||||
b.ToTable("canonical_recording_artists", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_canonical_recording_artist_position", "\"Position\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2093,6 +2243,19 @@ namespace allstarr.Core.Storage.Migrations
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Disambiguation")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long?>("DurationMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool?>("IsExplicit")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsProvisional")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isrc")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
@@ -2108,6 +2271,11 @@ namespace allstarr.Core.Storage.Migrations
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -2121,9 +2289,284 @@ namespace allstarr.Core.Storage.Migrations
|
||||
b.HasIndex("TenantId", "MusicBrainzRecordingId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "Title", "Id");
|
||||
|
||||
b.ToTable("canonical_recordings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupArtistRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalReleaseGroupId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("CanonicalArtistId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CreditName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("JoinPhrase")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("TenantId", "CanonicalReleaseGroupId", "Position");
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalArtistId", "CanonicalReleaseGroupId");
|
||||
|
||||
b.ToTable("canonical_release_group_artists", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_canonical_release_group_artist_position", "\"Position\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("FirstReleaseDate")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<bool>("IsProvisional")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MusicBrainzReleaseGroupId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("PrimaryType")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SecondaryTypesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "MusicBrainzReleaseGroupId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "Title", "Id");
|
||||
|
||||
b.ToTable("canonical_release_groups", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Barcode")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("CanonicalReleaseGroupId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Disambiguation")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsProvisional")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MusicBrainzReleaseId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ReleaseDate")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "MusicBrainzReleaseId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalReleaseGroupId", "ReleaseDate");
|
||||
|
||||
b.ToTable("canonical_releases", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseTrackRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalRecordingId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalReleaseId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("DurationMilliseconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("MediumPosition")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("MusicBrainzTrackId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("TrackPosition")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id");
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalRecordingId");
|
||||
|
||||
b.HasIndex("TenantId", "MusicBrainzTrackId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "CanonicalReleaseId", "MediumPosition", "TrackPosition")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("canonical_release_tracks", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_canonical_release_track_position", "\"MediumPosition\" > 0 AND \"TrackPosition\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CatalogFactRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CanonicalEntityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Confidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("EntityKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("FieldName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("ObservedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("PayloadSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long?>("RefreshAfter")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("SourceRevision")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long?>("SupersededAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "SourceId", "RefreshAfter");
|
||||
|
||||
b.HasIndex("TenantId", "EntityKind", "CanonicalEntityId", "FieldName");
|
||||
|
||||
b.ToTable("catalog_facts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_catalog_fact_confidence", "\"Confidence\" >= 0 AND \"Confidence\" <= 1");
|
||||
|
||||
t.HasCheckConstraint("CK_catalog_fact_payload_hash", "length(\"PayloadSha256\") = 64");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.DurableJobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -3402,6 +3845,9 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TrackRetention")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -3409,9 +3855,6 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasDefaultValue("OnDemand");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -4494,6 +4937,24 @@ namespace allstarr.Core.Storage.Migrations
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningEventRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_listening_event_provider_account");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ProviderTrackIdentityRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderTrackIdentityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_listening_event_provider_identity");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalRecordingId")
|
||||
@@ -4508,18 +4969,16 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_listening_event_library_track");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderAccountId")
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_listening_event_provider_account");
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.ProviderTrackIdentityRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProviderTrackIdentityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("FK_listening_event_provider_identity");
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningHistoryImportRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
@@ -4557,22 +5016,6 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningHistoryImportRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.PlatformUserRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OwnerUserId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Intelligence.ListeningProfileRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
@@ -4828,6 +5271,41 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalArtistRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalCatalogAliasRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingArtistRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalArtistRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalArtistId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalRecordingId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalRecordingRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
@@ -4844,6 +5322,80 @@ namespace allstarr.Core.Storage.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupArtistRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalArtistRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalArtistId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalReleaseGroupRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalReleaseGroupId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseGroupRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalReleaseGroupRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalReleaseGroupId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CanonicalReleaseTrackRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalRecordingRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalRecordingId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("allstarr.Core.Storage.CanonicalReleaseRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "CanonicalReleaseId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.CatalogFactRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.TenantRecord", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("allstarr.Core.Storage.DurableJobRecord", b =>
|
||||
{
|
||||
b.HasOne("allstarr.Core.Storage.ProviderAccountRecord", null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.WebSockets;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
|
||||
namespace allstarr.Middleware;
|
||||
@@ -81,20 +82,10 @@ public class WebSocketProxyMiddleware
|
||||
|
||||
try
|
||||
{
|
||||
// Extract device ID from query string or headers for session tracking
|
||||
deviceId = context.Request.Query["deviceId"].ToString();
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
// Try to extract from X-Emby-Authorization header
|
||||
if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var authHeader))
|
||||
{
|
||||
var authValue = authHeader.ToString();
|
||||
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(authValue, @"DeviceId=""([^""]+)""");
|
||||
if (deviceIdMatch.Success)
|
||||
{
|
||||
deviceId = deviceIdMatch.Groups[1].Value;
|
||||
}
|
||||
}
|
||||
deviceId = AuthHeaderHelper.ExtractDeviceId(context.Request.Headers);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
@@ -121,36 +112,16 @@ public class WebSocketProxyMiddleware
|
||||
// Connect to Jellyfin WebSocket
|
||||
serverWebSocket = new ClientWebSocket();
|
||||
|
||||
// Forward authentication headers - check X-Emby-Authorization FIRST
|
||||
// Most Jellyfin clients use X-Emby-Authorization, not Authorization
|
||||
if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
|
||||
var authorization = AuthHeaderHelper.BuildForwardedAuthorization(
|
||||
context.Request.Headers,
|
||||
deviceId);
|
||||
if (authorization is not null)
|
||||
{
|
||||
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header");
|
||||
}
|
||||
else if (context.Request.Headers.TryGetValue("X-Emby-Token", out var tokenHeader))
|
||||
{
|
||||
serverWebSocket.Options.SetRequestHeader("X-Emby-Token", tokenHeader.ToString());
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Token header");
|
||||
}
|
||||
else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader2))
|
||||
{
|
||||
var authValue = authHeader2.ToString();
|
||||
// If it's a MediaBrowser auth header, use X-Emby-Authorization
|
||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization header");
|
||||
}
|
||||
else
|
||||
{
|
||||
serverWebSocket.Options.SetRequestHeader("Authorization", authValue);
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Forwarded Authorization header");
|
||||
}
|
||||
serverWebSocket.Options.SetRequestHeader("Authorization", authorization);
|
||||
_logger.LogDebug("WEBSOCKET: Forwarded normalized Authorization header");
|
||||
}
|
||||
|
||||
// Set user agent
|
||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0.3");
|
||||
serverWebSocket.Options.SetRequestHeader("User-Agent", AppIdentity.UserAgent);
|
||||
|
||||
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
||||
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||
|
||||
@@ -5,6 +5,8 @@ public sealed class MusicBrainzSettings
|
||||
public bool Enabled { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string SourceId { get; set; } = "musicbrainz";
|
||||
public string BaseUrl { get; set; } = "https://musicbrainz.org/ws/2";
|
||||
public int RateLimitMs { get; set; } = 1000;
|
||||
public string? AuthorizedUserAgentOverride { get; set; }
|
||||
}
|
||||
+27
-11
@@ -41,10 +41,12 @@ using allstarr.Core.Intelligence;
|
||||
using allstarr.Core.Downloads;
|
||||
using allstarr.Core.Playback;
|
||||
using allstarr.Core.Settings;
|
||||
using allstarr.Core.Configuration;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using System.Net;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment);
|
||||
@@ -73,6 +75,8 @@ if (isStorageOperatorCommand)
|
||||
}
|
||||
|
||||
builder.Services.AddPlatformIdentity(builder.Configuration);
|
||||
var releaseComposition = ReleaseComposition.Resolve(builder.Configuration);
|
||||
builder.Services.AddSingleton(releaseComposition);
|
||||
builder.Services.AddSingleton<TrackMatchPolicy>();
|
||||
builder.Services.AddHostedService<DefaultTenantRuntimeSettingsProjector>();
|
||||
builder.Services.AddProtocolExecution(builder.Configuration);
|
||||
@@ -86,12 +90,16 @@ builder.Services.AddMetadataEnrichment();
|
||||
builder.Services.AddManagedFilePlacement();
|
||||
builder.Services.AddProviderDownloadArtifacts(builder.Configuration);
|
||||
builder.Services.AddFavoriteActions(builder.Configuration);
|
||||
builder.Services.AddIntelligenceCore();
|
||||
builder.Services.AddListeningHistoryImport(builder.Configuration);
|
||||
builder.Services.AddGeneratedSetMaterializers();
|
||||
builder.Services.AddBuiltInRecommendationSources();
|
||||
builder.Services.AddAudioMuseIntelligenceCapability();
|
||||
builder.Services.AddDurablePlaybackSignals();
|
||||
builder.Services.AddSingleton<IScopedRecommendationAccountAccessor, ScopedRecommendationAccountAccessor>();
|
||||
if (releaseComposition.IntelligenceEnabled)
|
||||
{
|
||||
builder.Services.AddIntelligenceCore();
|
||||
builder.Services.AddListeningHistoryImport(builder.Configuration);
|
||||
builder.Services.AddGeneratedSetMaterializers();
|
||||
builder.Services.AddBuiltInRecommendationSources();
|
||||
builder.Services.AddAudioMuseIntelligenceCapability();
|
||||
}
|
||||
builder.Services.AddDurablePlaybackSignals(releaseComposition.IntelligenceEnabled);
|
||||
builder.Services.AddPlaylistOrchestration();
|
||||
builder.Services.AddExtensionControlPlane();
|
||||
builder.Services.AddPlatformOperations(builder.Configuration);
|
||||
@@ -226,7 +234,7 @@ builder.Services.AddControllers()
|
||||
{
|
||||
manager.FeatureProviders.Remove(defaultProvider);
|
||||
}
|
||||
manager.FeatureProviders.Add(new BackendControllerFeatureProvider(backendType));
|
||||
manager.FeatureProviders.Add(new BackendControllerFeatureProvider(backendType, releaseComposition));
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
@@ -458,7 +466,7 @@ builder.Services.Configure<ScrobblingSettings>(builder.Configuration.GetSection(
|
||||
// Last.fm requires an identifying User-Agent.
|
||||
builder.Services.AddHttpClient("LastFm", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0 (https://github.com/sopat712/allstarr)");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(allstarr.AppIdentity.UserAgent);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
}).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
|
||||
@@ -468,13 +476,13 @@ builder.Services.AddSingleton<ScrobblingHelper>();
|
||||
builder.Services.Configure<MusicBrainzSettings>(builder.Configuration.GetSection("MusicBrainz"));
|
||||
builder.Services.AddHttpClient(allstarr.Services.MusicBrainz.MusicBrainzService.HttpClientName, client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
allstarr.Services.MusicBrainz.MusicBrainzService.UserAgent);
|
||||
client.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
}).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
|
||||
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.IMusicBrainzCatalogClient>(provider =>
|
||||
provider.GetRequiredService<allstarr.Services.MusicBrainz.MusicBrainzService>());
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
@@ -637,10 +645,12 @@ public partial class Program
|
||||
class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.ControllerFeatureProvider
|
||||
{
|
||||
private readonly BackendType _backendType;
|
||||
private readonly ReleaseComposition _releaseComposition;
|
||||
|
||||
public BackendControllerFeatureProvider(BackendType backendType)
|
||||
public BackendControllerFeatureProvider(BackendType backendType, ReleaseComposition releaseComposition)
|
||||
{
|
||||
_backendType = backendType;
|
||||
_releaseComposition = releaseComposition;
|
||||
}
|
||||
|
||||
protected override bool IsController(System.Reflection.TypeInfo typeInfo)
|
||||
@@ -648,6 +658,12 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
|
||||
var isController = base.IsController(typeInfo);
|
||||
if (!isController) return false;
|
||||
|
||||
var releaseFeature = typeInfo.GetCustomAttribute<ReleaseFeatureAttribute>();
|
||||
if (releaseFeature != null && !_releaseComposition.Includes(releaseFeature.Feature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only protocol catch-alls and their admin surfaces are conditional; an
|
||||
// allowlist would send future admin routes into the selected catch-all.
|
||||
if (typeInfo.Name == "JellyfinAdminController")
|
||||
|
||||
@@ -11,31 +11,33 @@ public static class AuthHeaderHelper
|
||||
|
||||
public static bool ForwardAuthHeaders(IHeaderDictionary sourceHeaders, HttpRequestMessage targetRequest)
|
||||
{
|
||||
if (sourceHeaders.TryGetValue("Authorization", out var authorization))
|
||||
var authorization = BuildForwardedAuthorization(sourceHeaders);
|
||||
return authorization is not null &&
|
||||
targetRequest.Headers.TryAddWithoutValidation("Authorization", authorization);
|
||||
}
|
||||
|
||||
public static string? BuildForwardedAuthorization(
|
||||
IHeaderDictionary sourceHeaders,
|
||||
string? fallbackDeviceId = null)
|
||||
{
|
||||
if (FirstValue(sourceHeaders, "Authorization") is { } authorization)
|
||||
{
|
||||
targetRequest.Headers.TryAddWithoutValidation("Authorization", authorization.ToString());
|
||||
return true;
|
||||
return authorization;
|
||||
}
|
||||
|
||||
if (sourceHeaders.TryGetValue("X-Emby-Authorization", out var mediaBrowser))
|
||||
if (FirstValue(sourceHeaders, "X-Emby-Authorization") is { } legacyAuthorization)
|
||||
{
|
||||
targetRequest.Headers.TryAddWithoutValidation("Authorization", mediaBrowser.ToString());
|
||||
return true;
|
||||
return legacyAuthorization;
|
||||
}
|
||||
|
||||
if (sourceHeaders.TryGetValue("X-Emby-Token", out var directToken))
|
||||
{
|
||||
var token = directToken.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
targetRequest.Headers.TryAddWithoutValidation(
|
||||
"Authorization",
|
||||
CreateAuthHeader(token, "Allstarr", "Proxy", "allstarr-proxy", "1"));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return FirstValue(sourceHeaders, "X-Emby-Token") is { } token
|
||||
? CreateAuthHeader(
|
||||
token,
|
||||
"Allstarr",
|
||||
"Proxy",
|
||||
string.IsNullOrWhiteSpace(fallbackDeviceId) ? "allstarr-proxy" : fallbackDeviceId,
|
||||
AppVersion.Version)
|
||||
: null;
|
||||
}
|
||||
|
||||
public static string? ExtractDeviceId(IHeaderDictionary headers) =>
|
||||
@@ -157,17 +159,17 @@ public static class AuthHeaderHelper
|
||||
|
||||
private static string? MediaBrowserAuthorization(IHeaderDictionary headers)
|
||||
{
|
||||
if (headers.TryGetValue("X-Emby-Authorization", out var native))
|
||||
if (FirstValue(headers, "Authorization") is { } authorization &&
|
||||
authorization.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return native.ToString();
|
||||
return authorization;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("Authorization", out var authorization) &&
|
||||
authorization.ToString().Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return authorization.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
return FirstValue(headers, "X-Emby-Authorization");
|
||||
}
|
||||
|
||||
private static string? FirstValue(IHeaderDictionary headers, string name) =>
|
||||
headers.TryGetValue(name, out var values)
|
||||
? values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
|
||||
: null;
|
||||
}
|
||||
@@ -169,21 +169,28 @@ public static class CacheKeyBuilder
|
||||
|
||||
#region MusicBrainz Keys
|
||||
|
||||
public static string BuildMusicBrainzIsrcKey(string isrc)
|
||||
public static string BuildMusicBrainzIsrcKey(string isrc, string sourceId = "musicbrainz")
|
||||
{
|
||||
return $"musicbrainz:isrc:v2:{Normalize(isrc)}";
|
||||
return $"musicbrainz:isrc:v3:{Normalize(sourceId)}:{Normalize(isrc)}";
|
||||
}
|
||||
|
||||
public static string BuildMusicBrainzSearchKey(string title, string artist, int limit)
|
||||
public static string BuildMusicBrainzSearchKey(
|
||||
string title,
|
||||
string artist,
|
||||
int limit,
|
||||
string sourceId = "musicbrainz")
|
||||
{
|
||||
return $"musicbrainz:search:v2:{DigestIdentity(title, artist, limit)}";
|
||||
return $"musicbrainz:search:v3:{Normalize(sourceId)}:{DigestIdentity(title, artist, limit)}";
|
||||
}
|
||||
|
||||
public static string BuildMusicBrainzMbidKey(string mbid)
|
||||
public static string BuildMusicBrainzMbidKey(string mbid, string sourceId = "musicbrainz")
|
||||
{
|
||||
return $"musicbrainz:mbid:v2:{Normalize(mbid)}";
|
||||
return BuildMusicBrainzResourceKey(sourceId, "recording", mbid);
|
||||
}
|
||||
|
||||
public static string BuildMusicBrainzResourceKey(string sourceId, string resourceKind, string mbid) =>
|
||||
$"musicbrainz:{Normalize(resourceKind)}:v3:{Normalize(sourceId)}:{Normalize(mbid)}";
|
||||
|
||||
public static string BuildMusicBrainzNegativeKey(string positiveKey) =>
|
||||
$"negative:{positiveKey}";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Downloads;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
@@ -18,11 +19,11 @@ public sealed class ManagedTrackCacheService(
|
||||
private readonly ConcurrentDictionary<string, byte> active = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public async Task<ProtocolProviderStream?> TryOpenAsync(ProviderExternalResourceId track,
|
||||
Guid? accountId, ProviderAudioQuality quality, CancellationToken cancellationToken)
|
||||
DownloadedSongMappingScope scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (quality != ProviderAudioQuality.Any) return null;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var path = await localLibrary.GetLocalPathForExternalSongAsync(track.ProviderId, track.Value);
|
||||
var path = await localLibrary.GetLocalPathForExternalSongAsync(
|
||||
scope, track.ProviderId, track.Value);
|
||||
if (path == null) return null;
|
||||
try
|
||||
{
|
||||
@@ -46,7 +47,8 @@ public sealed class ManagedTrackCacheService(
|
||||
return new ProtocolProviderStream(response, new ProviderStreamLease("managed-cache",
|
||||
new Uri("https://allstarr.invalid/managed-cache"), DateTimeOffset.UtcNow.AddHours(1),
|
||||
true, true, new ProviderMediaFormat(mime, extension, extension),
|
||||
ProviderStreamRetryBehavior.DoNotRetry), track.ProviderId, track.Value, accountId, IsCached: true);
|
||||
ProviderStreamRetryBehavior.DoNotRetry), track.ProviderId, track.Value,
|
||||
scope.ProviderAccountId, IsCached: true, EffectiveQuality: scope.AudioQuality);
|
||||
}
|
||||
catch (IOException) { return null; }
|
||||
catch (UnauthorizedAccessException) { return null; }
|
||||
@@ -54,24 +56,30 @@ public sealed class ManagedTrackCacheService(
|
||||
|
||||
public async Task WrapAsync(
|
||||
ProtocolProviderStream stream,
|
||||
ProtocolExecutionContext protocol,
|
||||
string providerId,
|
||||
string externalId,
|
||||
ProviderAudioQuality requestedQuality,
|
||||
bool headOnly,
|
||||
Func<Task<Song?>> metadataFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (stream.IsCached || !IsCacheMode() ||
|
||||
headOnly ||
|
||||
requestedQuality != ProviderAudioQuality.Any ||
|
||||
!IsCompleteResponse(stream.Response)) return;
|
||||
var actor = protocol.Actor;
|
||||
if (actor == null) return;
|
||||
var scope = new DownloadedSongMappingScope(
|
||||
actor.TenantId,
|
||||
stream.ServingAccountId,
|
||||
protocol.LibraryScopeId,
|
||||
stream.EffectiveQuality);
|
||||
|
||||
// Older callers without a resolved identity must never cache fallback bytes as the requested track.
|
||||
if (stream.ServingExternalId == null && stream.ServingProviderId != providerId) return;
|
||||
providerId = stream.ServingProviderId;
|
||||
externalId = stream.ServingExternalId ?? externalId;
|
||||
|
||||
var key = $"{providerId}\n{externalId}";
|
||||
var key = $"{scope.Key}\n{providerId}\n{externalId}";
|
||||
if (!active.TryAdd(key, 0)) return;
|
||||
|
||||
var cacheRoot = Path.Combine(
|
||||
@@ -139,7 +147,8 @@ public sealed class ManagedTrackCacheService(
|
||||
originalContent.Dispose();
|
||||
try
|
||||
{
|
||||
var existing = await localLibrary.GetLocalPathForExternalSongAsync(providerId, externalId);
|
||||
var existing = await localLibrary.GetLocalPathForExternalSongAsync(
|
||||
scope, providerId, externalId);
|
||||
if (existing != null && File.Exists(existing))
|
||||
{
|
||||
TryDelete(partialPath);
|
||||
@@ -180,7 +189,7 @@ public sealed class ManagedTrackCacheService(
|
||||
try
|
||||
{
|
||||
song.LocalPath = finalPath;
|
||||
await localLibrary.RegisterDownloadedSongAsync(song, finalPath);
|
||||
await localLibrary.RegisterDownloadedSongAsync(scope, song, finalPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Settings;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
@@ -592,14 +594,16 @@ public class MultiProviderMetadataService : IMusicMetadataService
|
||||
|
||||
private IReadOnlyList<string> ConfiguredSearchOrder(bool playbackOnly)
|
||||
{
|
||||
IEnumerable<string> values = playbackOnly
|
||||
? [
|
||||
_configuration["Providers:StreamingOrder"] ?? _configuration["MULTI_PROVIDER_STREAMING_ORDER"] ?? "apple-download,deezer,qobuz",
|
||||
_configuration["Providers:DownloadOrder"] ?? _configuration["MULTI_PROVIDER_DOWNLOAD_ORDER"] ?? "apple-download,deezer,qobuz"
|
||||
]
|
||||
: [
|
||||
_configuration["Providers:MetadataOrder"] ?? _configuration["MULTI_PROVIDER_METADATA_ORDER"] ?? "apple-download,deezer,qobuz"
|
||||
];
|
||||
var capabilities = playbackOnly
|
||||
? new[] { ProviderCapabilityKind.Streaming, ProviderCapabilityKind.Download }
|
||||
: [ProviderCapabilityKind.Metadata];
|
||||
var values = capabilities.Select(capability =>
|
||||
{
|
||||
var definition = ProviderOrderPolicyCatalog.Find(capability)!;
|
||||
return _configuration[definition.SettingKey] ??
|
||||
_configuration[definition.BootstrapKey] ??
|
||||
definition.DefaultValue;
|
||||
});
|
||||
|
||||
var configuredOrder = values
|
||||
.SelectMany(value => value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
|
||||
@@ -54,7 +54,8 @@ public sealed record PlaybackStreamSource(
|
||||
string BackendInstanceId,
|
||||
string? LibraryScopeId,
|
||||
bool Cached,
|
||||
DateTimeOffset OpenedAt)
|
||||
DateTimeOffset OpenedAt,
|
||||
string SelectionReason)
|
||||
{
|
||||
public bool Matches(string protocol, string backendInstanceId, string? libraryScopeId) =>
|
||||
Protocol == protocol && BackendInstanceId == backendInstanceId &&
|
||||
@@ -96,7 +97,8 @@ public sealed class PlaybackDeliveryActivityStore : IPlaybackDeliveryActivitySou
|
||||
string.IsNullOrWhiteSpace(context.Client.DeviceId) || stream.ServingExternalId == null) return;
|
||||
var source = new PlaybackStreamSource(stream.ServingProviderId, stream.ServingExternalId,
|
||||
stream.ServingAccountId, context.Protocol.ToString().ToLowerInvariant(),
|
||||
context.BackendInstanceId, context.LibraryScopeId, stream.IsCached, DateTimeOffset.UtcNow);
|
||||
context.BackendInstanceId, context.LibraryScopeId, stream.IsCached, DateTimeOffset.UtcNow,
|
||||
stream.SelectionReason);
|
||||
var key = (context.Actor.TenantId, userId, context.Client.DeviceId, StreamItemKey(itemId));
|
||||
Set(key, source);
|
||||
Set((key, context.Protocol, context.BackendInstanceId, context.LibraryScopeId, quality), source);
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Settings;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -1095,25 +1096,28 @@ public class ProviderStatusManager
|
||||
}
|
||||
|
||||
private List<string> GetMetadataOrder() =>
|
||||
GetProviderOrder("MULTI_PROVIDER_METADATA_ORDER", "apple-download,deezer,qobuz");
|
||||
GetProviderOrder(ProviderCapabilityKind.Metadata);
|
||||
|
||||
private List<string> GetDownloadOrder() =>
|
||||
GetProviderOrder("MULTI_PROVIDER_DOWNLOAD_ORDER", "apple-download,deezer,qobuz");
|
||||
GetProviderOrder(ProviderCapabilityKind.Download);
|
||||
|
||||
private List<string> GetStreamingOrder() =>
|
||||
GetProviderOrder("MULTI_PROVIDER_STREAMING_ORDER", "apple-download,deezer,qobuz");
|
||||
GetProviderOrder(ProviderCapabilityKind.Streaming);
|
||||
|
||||
private List<string> GetPlaylistOrder() =>
|
||||
GetProviderOrder("MULTI_PROVIDER_PLAYLIST_ORDER", "spotify,deezer,qobuz");
|
||||
GetProviderOrder(ProviderCapabilityKind.Playlist);
|
||||
|
||||
private List<string> GetLyricsOrder() =>
|
||||
GetProviderOrder("MULTI_PROVIDER_LYRICS_ORDER", "spotify,apple-download,lrclib")
|
||||
GetProviderOrder(ProviderCapabilityKind.Lyrics)
|
||||
.Where(provider => provider != "lyricsplus")
|
||||
.ToList();
|
||||
|
||||
private List<string> GetProviderOrder(string key, string fallback)
|
||||
private List<string> GetProviderOrder(ProviderCapabilityKind capability)
|
||||
{
|
||||
var value = _configuration[key] ?? fallback;
|
||||
var definition = ProviderOrderPolicyCatalog.Find(capability)!;
|
||||
var value = _configuration[definition.SettingKey] ??
|
||||
_configuration[definition.BootstrapKey] ??
|
||||
definition.DefaultValue;
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(Normalize)
|
||||
.ToList();
|
||||
@@ -1126,7 +1130,10 @@ public class ProviderStatusManager
|
||||
GetProviderSet("MULTI_PROVIDER_ENABLED_PLAYLIST", "spotify");
|
||||
|
||||
private HashSet<string> GetProviderSet(string key, string fallback) =>
|
||||
GetProviderOrder(key, fallback).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
(_configuration[key] ?? fallback)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(Normalize)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private HashSet<string> GetDisabledProviders() =>
|
||||
GetProviderSet("MULTI_PROVIDER_DISABLED_PROVIDERS", string.Empty);
|
||||
|
||||
@@ -21,6 +21,7 @@ public static class RuntimeEnvConfiguration
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["BACKEND_TYPE"] = ["Backend:Type"],
|
||||
["ALLSTARR_RELEASE_PROFILE"] = ["Release:Profile"],
|
||||
["ADMIN_BIND_ANY_IP"] = ["Admin:BindAnyIp"],
|
||||
["ADMIN_TRUSTED_SUBNETS"] = ["Admin:TrustedSubnets"],
|
||||
["ADMIN_ENABLE_ENV_EXPORT"] = ["Admin:EnableEnvExport"],
|
||||
@@ -102,6 +103,10 @@ public static class RuntimeEnvConfiguration
|
||||
["MUSICBRAINZ_ENABLED"] = ["MusicBrainz:Enabled"],
|
||||
["MUSICBRAINZ_USERNAME"] = ["MusicBrainz:Username"],
|
||||
["MUSICBRAINZ_PASSWORD"] = ["MusicBrainz:Password"],
|
||||
["MUSICBRAINZ_SOURCE_ID"] = ["MusicBrainz:SourceId"],
|
||||
["MUSICBRAINZ_BASE_URL"] = ["MusicBrainz:BaseUrl"],
|
||||
["MUSICBRAINZ_RATE_LIMIT_MS"] = ["MusicBrainz:RateLimitMs"],
|
||||
["MUSICBRAINZ_AUTHORIZED_USER_AGENT_OVERRIDE"] = ["MusicBrainz:AuthorizedUserAgentOverride"],
|
||||
|
||||
["MULTI_PROVIDER_METADATA_ORDER"] = ["MULTI_PROVIDER_METADATA_ORDER"],
|
||||
["MULTI_PROVIDER_DOWNLOAD_ORDER"] = ["MULTI_PROVIDER_DOWNLOAD_ORDER"],
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Core.Downloads;
|
||||
|
||||
namespace allstarr.Services.Local;
|
||||
|
||||
@@ -16,11 +17,21 @@ public interface ILocalLibraryService
|
||||
/// </summary>
|
||||
Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId);
|
||||
|
||||
Task<string?> GetLocalPathForExternalSongAsync(
|
||||
DownloadedSongMappingScope scope,
|
||||
string externalProvider,
|
||||
string externalId);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a downloaded song in the local library
|
||||
/// </summary>
|
||||
Task RegisterDownloadedSongAsync(Song song, string localPath);
|
||||
|
||||
Task RegisterDownloadedSongAsync(
|
||||
DownloadedSongMappingScope scope,
|
||||
Song song,
|
||||
string localPath);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a song ID to determine if it is external or local
|
||||
/// </summary>
|
||||
|
||||
@@ -48,21 +48,19 @@ public class LocalLibraryService : ILocalLibraryService
|
||||
var mapping = await _downloadedSongs.FindAsync(
|
||||
NormalizeProvider(externalProvider),
|
||||
externalId.Trim());
|
||||
if (mapping is not null)
|
||||
{
|
||||
if (File.Exists(mapping.LocalPath))
|
||||
{
|
||||
return mapping.LocalPath;
|
||||
}
|
||||
return await ExistingPathAsync(mapping);
|
||||
}
|
||||
|
||||
await _downloadedSongs.RemoveAsync(mapping.Id, mapping.Revision);
|
||||
_logger.LogDebug(
|
||||
"Removed stale downloaded-song mapping for {ProviderId}:{ExternalId} because its file is missing",
|
||||
mapping.ProviderId,
|
||||
mapping.ExternalId);
|
||||
}
|
||||
|
||||
return null;
|
||||
public async Task<string?> GetLocalPathForExternalSongAsync(
|
||||
DownloadedSongMappingScope scope,
|
||||
string externalProvider,
|
||||
string externalId)
|
||||
{
|
||||
var mapping = await _downloadedSongs.FindAsync(
|
||||
scope,
|
||||
NormalizeProvider(externalProvider),
|
||||
externalId.Trim());
|
||||
return await ExistingPathAsync(mapping);
|
||||
}
|
||||
|
||||
public async Task RegisterDownloadedSongAsync(Song song, string localPath)
|
||||
@@ -83,6 +81,45 @@ public class LocalLibraryService : ILocalLibraryService
|
||||
});
|
||||
}
|
||||
|
||||
public async Task RegisterDownloadedSongAsync(
|
||||
DownloadedSongMappingScope scope,
|
||||
Song song,
|
||||
string localPath)
|
||||
{
|
||||
if (song.ExternalProvider == null || song.ExternalId == null) return;
|
||||
|
||||
await _downloadedSongs.UpsertAsync(new DownloadedSongMappingEntity
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
ScopeKey = scope.Key,
|
||||
TenantId = scope.TenantId,
|
||||
ProviderAccountId = scope.ProviderAccountId,
|
||||
LibraryScopeId = scope.LibraryScopeId,
|
||||
AudioQuality = scope.AudioQuality,
|
||||
ProviderId = NormalizeProvider(song.ExternalProvider),
|
||||
ExternalId = song.ExternalId.Trim(),
|
||||
LocalPath = Path.GetFullPath(localPath),
|
||||
Title = song.Title,
|
||||
Artist = song.Artist,
|
||||
Album = song.Album,
|
||||
DownloadedAt = DateTimeOffset.UtcNow,
|
||||
Revision = 1
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string?> ExistingPathAsync(DownloadedSongMappingEntity? mapping)
|
||||
{
|
||||
if (mapping == null) return null;
|
||||
if (File.Exists(mapping.LocalPath)) return mapping.LocalPath;
|
||||
|
||||
await _downloadedSongs.RemoveAsync(mapping.Id, mapping.Revision);
|
||||
_logger.LogDebug(
|
||||
"Removed stale downloaded-song mapping for {ProviderId}:{ExternalId} because its file is missing",
|
||||
mapping.ProviderId,
|
||||
mapping.ExternalId);
|
||||
return null;
|
||||
}
|
||||
|
||||
public (bool isExternal, string? provider, string? externalId) ParseSongId(string songId)
|
||||
{
|
||||
var (isExternal, provider, _, externalId) = ParseExternalId(songId);
|
||||
|
||||
@@ -25,7 +25,7 @@ public class LrclibService
|
||||
ILogger<LrclibService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("Lrclib");
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)");
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(AppIdentity.UserAgent);
|
||||
_cache = cache;
|
||||
_mappingStore = mappingStore;
|
||||
_lyricsTtl = cacheSettings.Value.LyricsTTL;
|
||||
|
||||
@@ -27,15 +27,24 @@ public sealed class MusicBrainzLookupException(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded public MusicBrainz read client. Public reads never use stored credentials.
|
||||
/// Bounded MusicBrainz-compatible catalog client. Public reads never use stored credentials.
|
||||
/// </summary>
|
||||
public sealed class MusicBrainzService
|
||||
public interface IMusicBrainzCatalogClient
|
||||
{
|
||||
string ConfiguredSourceId { get; }
|
||||
string ConfiguredSourceRevision { get; }
|
||||
Task<MusicBrainzRecording?> LookupByMbidAsync(string mbid, CancellationToken cancellationToken = default);
|
||||
Task<MusicBrainzArtist?> LookupArtistByMbidAsync(string mbid, CancellationToken cancellationToken = default);
|
||||
Task<MusicBrainzReleaseGroup?> LookupReleaseGroupByMbidAsync(string mbid, CancellationToken cancellationToken = default);
|
||||
Task<MusicBrainzRelease?> LookupReleaseByMbidAsync(string mbid, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class MusicBrainzService : IMusicBrainzCatalogClient
|
||||
{
|
||||
public const string HttpClientName = "MusicBrainz";
|
||||
public const string SourceRevision = "musicbrainz:ws2";
|
||||
public const int MaximumResponseBytes = 1024 * 1024;
|
||||
public static readonly string UserAgent =
|
||||
$"Allstarr/{AppVersion.Version} (https://github.com/SoPat712/allstarr)";
|
||||
public static readonly string UserAgent = AppIdentity.UserAgent;
|
||||
|
||||
private static readonly TimeSpan NegativeCacheDuration = TimeSpan.FromHours(6);
|
||||
private readonly HttpClient _httpClient;
|
||||
@@ -44,6 +53,9 @@ public sealed class MusicBrainzService
|
||||
private readonly IApplicationCache _cache;
|
||||
private readonly ApplicationCacheRequestCoalescer _coalescer;
|
||||
private readonly ILogger<MusicBrainzService> _logger;
|
||||
private readonly string _sourceId;
|
||||
private readonly string _sourceRevision;
|
||||
private readonly string _userAgent;
|
||||
private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
|
||||
private DateTimeOffset _lastRequestAt = DateTimeOffset.MinValue;
|
||||
|
||||
@@ -55,20 +67,39 @@ public sealed class MusicBrainzService
|
||||
ApplicationCacheRequestCoalescer coalescer,
|
||||
ILogger<MusicBrainzService> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_sourceId = NormalizeSourceId(_settings.SourceId);
|
||||
_sourceRevision = $"{_sourceId}:ws2";
|
||||
_userAgent = ResolveUserAgent(_settings.AuthorizedUserAgentOverride);
|
||||
_httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
_httpClient.DefaultRequestHeaders.Authorization = null;
|
||||
if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0)
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Clear();
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
|
||||
if (_httpClient.DefaultRequestHeaders.Accept.Count == 0)
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_settings = settings.Value;
|
||||
_cacheSettings = cacheSettings.Value;
|
||||
_cache = cache;
|
||||
_coalescer = coalescer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string ConfiguredSourceId => _sourceId;
|
||||
|
||||
public string ConfiguredSourceRevision => _sourceRevision;
|
||||
|
||||
public string ConfiguredUserAgent => _userAgent;
|
||||
|
||||
private static string ResolveUserAgent(string? authorizedOverride)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizedOverride)) return UserAgent;
|
||||
|
||||
var candidate = authorizedOverride.Trim();
|
||||
if (candidate.Length > 256 || candidate.Contains('\r') || candidate.Contains('\n'))
|
||||
throw new InvalidOperationException("The authorized catalog User-Agent override is invalid.");
|
||||
return candidate;
|
||||
}
|
||||
|
||||
public Task<MusicBrainzRecording?> LookupByIsrcAsync(
|
||||
string isrc,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -76,7 +107,7 @@ public sealed class MusicBrainzService
|
||||
var normalized = NormalizeIsrc(isrc) ??
|
||||
throw new ArgumentException("ISRC must contain a valid 12-character recording code.", nameof(isrc));
|
||||
if (!_settings.Enabled) return Task.FromResult<MusicBrainzRecording?>(null);
|
||||
var key = CacheKeyBuilder.BuildMusicBrainzIsrcKey(normalized);
|
||||
var key = CacheKeyBuilder.BuildMusicBrainzIsrcKey(normalized, _sourceId);
|
||||
return GetCachedAsync(key, token => LookupIsrcUncachedAsync(normalized, token), cancellationToken);
|
||||
}
|
||||
|
||||
@@ -91,7 +122,11 @@ public sealed class MusicBrainzService
|
||||
if (limit is < 1 or > 25)
|
||||
throw new ArgumentOutOfRangeException(nameof(limit), "MusicBrainz search limit must be between 1 and 25.");
|
||||
if (!_settings.Enabled) return Task.FromResult<IReadOnlyList<MusicBrainzRecording>>([]);
|
||||
var key = CacheKeyBuilder.BuildMusicBrainzSearchKey(normalizedTitle, normalizedArtist, limit);
|
||||
var key = CacheKeyBuilder.BuildMusicBrainzSearchKey(
|
||||
normalizedTitle,
|
||||
normalizedArtist,
|
||||
limit,
|
||||
_sourceId);
|
||||
return GetCachedListAsync(key,
|
||||
token => SearchUncachedAsync(normalizedTitle, normalizedArtist, limit, token), cancellationToken);
|
||||
}
|
||||
@@ -103,10 +138,58 @@ public sealed class MusicBrainzService
|
||||
var normalized = NormalizeMbid(mbid) ??
|
||||
throw new ArgumentException("MusicBrainz recording ID must be a non-empty UUID.", nameof(mbid));
|
||||
if (!_settings.Enabled) return Task.FromResult<MusicBrainzRecording?>(null);
|
||||
var key = CacheKeyBuilder.BuildMusicBrainzMbidKey(normalized);
|
||||
var key = CacheKeyBuilder.BuildMusicBrainzMbidKey(normalized, _sourceId);
|
||||
return GetCachedAsync(key, token => LookupMbidUncachedAsync(normalized, token), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MusicBrainzArtist?> LookupArtistByMbidAsync(
|
||||
string mbid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = NormalizeMbid(mbid) ??
|
||||
throw new ArgumentException("MusicBrainz artist ID must be a non-empty UUID.", nameof(mbid));
|
||||
if (!_settings.Enabled) return Task.FromResult<MusicBrainzArtist?>(null);
|
||||
var key = CacheKeyBuilder.BuildMusicBrainzResourceKey(_sourceId, "artist", normalized);
|
||||
return GetCachedAsync(
|
||||
key,
|
||||
token => GetJsonAsync<MusicBrainzArtist>(
|
||||
$"artist/{normalized}?fmt=json&inc=aliases",
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MusicBrainzReleaseGroup?> LookupReleaseGroupByMbidAsync(
|
||||
string mbid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = NormalizeMbid(mbid) ??
|
||||
throw new ArgumentException("MusicBrainz release-group ID must be a non-empty UUID.", nameof(mbid));
|
||||
if (!_settings.Enabled) return Task.FromResult<MusicBrainzReleaseGroup?>(null);
|
||||
var key = CacheKeyBuilder.BuildMusicBrainzResourceKey(_sourceId, "release-group", normalized);
|
||||
return GetCachedAsync(
|
||||
key,
|
||||
token => GetJsonAsync<MusicBrainzReleaseGroup>(
|
||||
$"release-group/{normalized}?fmt=json&inc=artist-credits+aliases",
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MusicBrainzRelease?> LookupReleaseByMbidAsync(
|
||||
string mbid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = NormalizeMbid(mbid) ??
|
||||
throw new ArgumentException("MusicBrainz release ID must be a non-empty UUID.", nameof(mbid));
|
||||
if (!_settings.Enabled) return Task.FromResult<MusicBrainzRelease?>(null);
|
||||
var key = CacheKeyBuilder.BuildMusicBrainzResourceKey(_sourceId, "release", normalized);
|
||||
return GetCachedAsync(
|
||||
key,
|
||||
token => GetJsonAsync<MusicBrainzRelease>(
|
||||
$"release/{normalized}?fmt=json&inc=artist-credits+release-groups+recordings+media+labels",
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MusicBrainzRecordingMatch?> ResolveRecordingAsync(
|
||||
string? recordingMbid,
|
||||
string? isrc,
|
||||
@@ -121,13 +204,13 @@ public sealed class MusicBrainzService
|
||||
if (!string.IsNullOrWhiteSpace(recordingMbid))
|
||||
{
|
||||
var recording = await LookupByMbidAsync(recordingMbid, cancellationToken);
|
||||
if (recording != null) return new(recording, 1, SourceRevision);
|
||||
if (recording != null) return new(recording, 1, _sourceRevision);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(isrc))
|
||||
{
|
||||
var recording = await LookupByIsrcAsync(isrc, cancellationToken);
|
||||
if (recording != null) return new(recording, .98, SourceRevision);
|
||||
if (recording != null) return new(recording, .98, _sourceRevision);
|
||||
}
|
||||
|
||||
var normalizedTitle = NormalizeRequiredText(title, nameof(title));
|
||||
@@ -137,7 +220,7 @@ public sealed class MusicBrainzService
|
||||
var selected = SelectCandidate(candidates, normalizedTitle, normalizedArtist, durationMilliseconds);
|
||||
if (selected == null) return null;
|
||||
var full = await LookupByMbidAsync(selected.Value.Recording.Id!, cancellationToken);
|
||||
return full == null ? null : new(full, selected.Value.Confidence, SourceRevision);
|
||||
return full == null ? null : new(full, selected.Value.Confidence, _sourceRevision);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetGenresForSongAsync(
|
||||
@@ -210,12 +293,17 @@ public sealed class MusicBrainzService
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound) return null;
|
||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
throw new MusicBrainzLookupException(
|
||||
"catalog_source_access_denied",
|
||||
$"Canonical metadata source '{_sourceId}' denied Allstarr access.",
|
||||
false);
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests ||
|
||||
response.StatusCode == HttpStatusCode.ServiceUnavailable ||
|
||||
(int)response.StatusCode is >= 500 and <= 599)
|
||||
throw new MusicBrainzLookupException(
|
||||
"musicbrainz_temporarily_unavailable",
|
||||
"MusicBrainz is temporarily unavailable.",
|
||||
"catalog_source_temporarily_unavailable",
|
||||
$"Canonical metadata source '{_sourceId}' is temporarily unavailable.",
|
||||
true,
|
||||
RetryAfter(response));
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -225,8 +313,8 @@ public sealed class MusicBrainzService
|
||||
}
|
||||
if (response.Content.Headers.ContentLength > MaximumResponseBytes)
|
||||
throw new MusicBrainzLookupException(
|
||||
"musicbrainz_response_too_large",
|
||||
"MusicBrainz returned more metadata than Allstarr can safely process.",
|
||||
"catalog_source_response_too_large",
|
||||
$"Canonical metadata source '{_sourceId}' returned more data than Allstarr can safely process.",
|
||||
false);
|
||||
try
|
||||
{
|
||||
@@ -237,16 +325,16 @@ public sealed class MusicBrainzService
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw new MusicBrainzLookupException(
|
||||
"musicbrainz_response_too_large",
|
||||
"MusicBrainz returned more metadata than Allstarr can safely process.",
|
||||
"catalog_source_response_too_large",
|
||||
$"Canonical metadata source '{_sourceId}' returned more data than Allstarr can safely process.",
|
||||
false,
|
||||
innerException: exception);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new MusicBrainzLookupException(
|
||||
"musicbrainz_response_invalid",
|
||||
"MusicBrainz returned invalid metadata.",
|
||||
"catalog_source_response_invalid",
|
||||
$"Canonical metadata source '{_sourceId}' returned invalid metadata.",
|
||||
false,
|
||||
innerException: exception);
|
||||
}
|
||||
@@ -262,33 +350,33 @@ public sealed class MusicBrainzService
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw new MusicBrainzLookupException(
|
||||
"musicbrainz_temporarily_unavailable",
|
||||
"MusicBrainz is temporarily unavailable.",
|
||||
"catalog_source_temporarily_unavailable",
|
||||
$"Canonical metadata source '{_sourceId}' is temporarily unavailable.",
|
||||
true,
|
||||
innerException: exception);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<MusicBrainzRecording?> GetCachedAsync(
|
||||
private async Task<T?> GetCachedAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<MusicBrainzRecording?>> fetch,
|
||||
CancellationToken cancellationToken)
|
||||
Func<CancellationToken, Task<T?>> fetch,
|
||||
CancellationToken cancellationToken) where T : class
|
||||
{
|
||||
var cached = await _cache.GetAsync<MusicBrainzCacheEntry<MusicBrainzRecording>>(key);
|
||||
if (cached is { SourceRevision: SourceRevision }) return cached.Value;
|
||||
var cached = await _cache.GetAsync<MusicBrainzCacheEntry<T>>(key);
|
||||
if (cached is not null && cached.SourceRevision == _sourceRevision) return cached.Value;
|
||||
var negativeKey = CacheKeyBuilder.BuildMusicBrainzNegativeKey(key);
|
||||
if (await _cache.ExistsAsync(negativeKey)) return null;
|
||||
return await _coalescer.RunAsync(key, async () =>
|
||||
{
|
||||
cached = await _cache.GetAsync<MusicBrainzCacheEntry<MusicBrainzRecording>>(key);
|
||||
if (cached is { SourceRevision: SourceRevision }) return cached.Value;
|
||||
cached = await _cache.GetAsync<MusicBrainzCacheEntry<T>>(key);
|
||||
if (cached is not null && cached.SourceRevision == _sourceRevision) return cached.Value;
|
||||
if (await _cache.ExistsAsync(negativeKey)) return null;
|
||||
var result = await fetch(cancellationToken);
|
||||
if (result == null)
|
||||
await _cache.SetStringAsync(negativeKey, SourceRevision, NegativeCacheDuration);
|
||||
await _cache.SetStringAsync(negativeKey, _sourceRevision, NegativeCacheDuration);
|
||||
else
|
||||
await _cache.SetAsync(key, new MusicBrainzCacheEntry<MusicBrainzRecording>(
|
||||
result, SourceRevision), _cacheSettings.GenreTTL);
|
||||
await _cache.SetAsync(key, new MusicBrainzCacheEntry<T>(
|
||||
result, _sourceRevision), _cacheSettings.GenreTTL);
|
||||
return result;
|
||||
}, cancellationToken);
|
||||
}
|
||||
@@ -298,23 +386,11 @@ public sealed class MusicBrainzService
|
||||
Func<CancellationToken, Task<IReadOnlyList<MusicBrainzRecording>?>> fetch,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cached = await _cache.GetAsync<MusicBrainzCacheEntry<List<MusicBrainzRecording>>>(key);
|
||||
if (cached is { SourceRevision: SourceRevision }) return cached.Value;
|
||||
var negativeKey = CacheKeyBuilder.BuildMusicBrainzNegativeKey(key);
|
||||
if (await _cache.ExistsAsync(negativeKey)) return [];
|
||||
return await _coalescer.RunAsync<IReadOnlyList<MusicBrainzRecording>>(key, async () =>
|
||||
{
|
||||
cached = await _cache.GetAsync<MusicBrainzCacheEntry<List<MusicBrainzRecording>>>(key);
|
||||
if (cached is { SourceRevision: SourceRevision }) return cached.Value;
|
||||
if (await _cache.ExistsAsync(negativeKey)) return [];
|
||||
var result = (await fetch(cancellationToken))?.ToList() ?? [];
|
||||
if (result.Count == 0)
|
||||
await _cache.SetStringAsync(negativeKey, SourceRevision, NegativeCacheDuration);
|
||||
else
|
||||
await _cache.SetAsync(key, new MusicBrainzCacheEntry<List<MusicBrainzRecording>>(
|
||||
result, SourceRevision), _cacheSettings.GenreTTL);
|
||||
return result;
|
||||
}, cancellationToken);
|
||||
var result = await GetCachedAsync<List<MusicBrainzRecording>>(
|
||||
key,
|
||||
async token => (await fetch(token))?.ToList() is { Count: > 0 } values ? values : null,
|
||||
cancellationToken);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
private async Task RateLimitAsync(CancellationToken cancellationToken)
|
||||
@@ -429,6 +505,19 @@ public sealed class MusicBrainzService
|
||||
string.Concat((value ?? string.Empty).Normalize(NormalizationForm.FormKC)
|
||||
.Where(char.IsLetterOrDigit)).ToUpperInvariant();
|
||||
|
||||
private static string NormalizeSourceId(string? value)
|
||||
{
|
||||
var normalized = value?.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalized) ||
|
||||
normalized.Length > 50 ||
|
||||
normalized.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_'))
|
||||
{
|
||||
throw new InvalidOperationException("MusicBrainz source ID is invalid.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static TimeSpan? RetryAfter(HttpResponseMessage response)
|
||||
{
|
||||
var retry = response.Headers.RetryAfter;
|
||||
@@ -553,6 +642,8 @@ public sealed class MusicBrainzRelease
|
||||
public List<MusicBrainzArtistCredit>? ArtistCredit { get; set; }
|
||||
[JsonPropertyName("label-info")]
|
||||
public List<MusicBrainzLabelInfo>? LabelInfo { get; set; }
|
||||
[JsonPropertyName("media")]
|
||||
public List<MusicBrainzMedium>? Media { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MusicBrainzReleaseGroup
|
||||
@@ -567,6 +658,40 @@ public sealed class MusicBrainzReleaseGroup
|
||||
public List<string>? SecondaryTypes { get; set; }
|
||||
[JsonPropertyName("first-release-date")]
|
||||
public string? FirstReleaseDate { get; set; }
|
||||
[JsonPropertyName("artist-credit")]
|
||||
public List<MusicBrainzArtistCredit>? ArtistCredit { get; set; }
|
||||
[JsonPropertyName("aliases")]
|
||||
public List<MusicBrainzAlias>? Aliases { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MusicBrainzMedium
|
||||
{
|
||||
[JsonPropertyName("position")]
|
||||
public int Position { get; set; }
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; set; }
|
||||
[JsonPropertyName("track-count")]
|
||||
public int TrackCount { get; set; }
|
||||
[JsonPropertyName("tracks")]
|
||||
public List<MusicBrainzReleaseTrack>? Tracks { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MusicBrainzReleaseTrack
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
[JsonPropertyName("position")]
|
||||
public int Position { get; set; }
|
||||
[JsonPropertyName("number")]
|
||||
public string? Number { get; set; }
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
[JsonPropertyName("length")]
|
||||
public int? Length { get; set; }
|
||||
[JsonPropertyName("recording")]
|
||||
public MusicBrainzRecording? Recording { get; set; }
|
||||
[JsonPropertyName("artist-credit")]
|
||||
public List<MusicBrainzArtistCredit>? ArtistCredit { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MusicBrainzLabelInfo
|
||||
|
||||
@@ -5,6 +5,7 @@ using allstarr.Core.Identity;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Core.Settings;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
@@ -19,7 +20,8 @@ public sealed class PlaylistPlayableSearchService(
|
||||
BackendIdentityResolver identities,
|
||||
IdentityOptions identityOptions,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
ILogger<PlaylistPlayableSearchService> logger)
|
||||
ILogger<PlaylistPlayableSearchService> logger,
|
||||
IEffectiveProviderPolicyResolver? effectivePolicies = null)
|
||||
{
|
||||
private readonly SemaphoreSlim _principalLock = new(1, 1);
|
||||
private AllstarrPrincipal? _principal;
|
||||
@@ -43,8 +45,9 @@ public sealed class PlaylistPlayableSearchService(
|
||||
$"playlist-match-{Guid.NewGuid():N}",
|
||||
DateTimeOffset.UtcNow.AddSeconds(30),
|
||||
cancellationToken);
|
||||
var providerOrder = await ProviderOrderAsync(principal.TenantId, cancellationToken);
|
||||
return (await gateway.SearchPlayableSongsAsync(context, query, limit))
|
||||
.Where(IsPlayable)
|
||||
.Where(song => IsPlayable(song, providerOrder))
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
@@ -71,33 +74,16 @@ public sealed class PlaylistPlayableSearchService(
|
||||
.Select(query => query!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var (providerOrder, effectiveMatcher) = await MatchingPolicyAsync(scope.TenantId, cancellationToken);
|
||||
var songs = (await Task.WhenAll(queries.Select(SearchAsync)))
|
||||
.SelectMany(result => result)
|
||||
.DistinctBy(song => $"{song.ExternalProvider}:{song.ExternalId}", StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var groups = GroupEquivalent(songs, scope);
|
||||
var external = songs.ToDictionary(
|
||||
song => CandidateId(song.ExternalProvider!, song.ExternalId!),
|
||||
song => song);
|
||||
var candidates = localCandidates
|
||||
.Concat(groups.Select(group => ToCandidate(group[0], scope)))
|
||||
.ToArray();
|
||||
var decision = matcher.Decide(
|
||||
scope,
|
||||
source,
|
||||
candidates,
|
||||
manualOverride,
|
||||
gateway.GetProviderOrder(ProviderCapabilityKind.Streaming));
|
||||
return new(
|
||||
decision,
|
||||
external,
|
||||
groups.ToDictionary(
|
||||
group => CandidateId(group[0].ExternalProvider!, group[0].ExternalId!),
|
||||
group => (IReadOnlyList<Song>)group));
|
||||
return DecideMatch(source, scope, songs, localCandidates, manualOverride, providerOrder, effectiveMatcher);
|
||||
|
||||
async Task<Song[]> SearchAsync(string query) =>
|
||||
((await gateway.SearchPlayableSongsAsync(context, query, 60)) ?? [])
|
||||
.Where(IsPlayable)
|
||||
.Where(song => IsPlayable(song, providerOrder))
|
||||
.Where(song => !string.IsNullOrWhiteSpace(song.ExternalProvider) &&
|
||||
!string.IsNullOrWhiteSpace(song.ExternalId))
|
||||
.Where(song => !song.ExternalProvider!.Equals(source.ProviderId, StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -114,9 +100,12 @@ public sealed class PlaylistPlayableSearchService(
|
||||
ExternalTrackMatchSnapshot source,
|
||||
TrackMatchScope scope,
|
||||
IEnumerable<ProviderTrackIdentityRecord> identities,
|
||||
IReadOnlyList<LocalTrackMatchCandidate> localCandidates,
|
||||
ScopedTrackMatchOverride? manualOverride,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var order = ProviderRanks();
|
||||
var (providerOrder, effectiveMatcher) = await MatchingPolicyAsync(scope.TenantId, cancellationToken);
|
||||
var order = ProviderRanks(providerOrder);
|
||||
var cachedRoutes = identities
|
||||
.Where(identity => identity.VerificationMethod != "automatic-suggestion")
|
||||
.Where(identity => order.ContainsKey(
|
||||
@@ -139,34 +128,39 @@ public sealed class PlaylistPlayableSearchService(
|
||||
cached.ExternalId);
|
||||
continue;
|
||||
}
|
||||
if (song == null) continue;
|
||||
if (song == null || !IsPlayable(song, providerOrder) || string.IsNullOrWhiteSpace(song.ExternalId)) continue;
|
||||
|
||||
var candidate = ToCandidate(song, scope);
|
||||
var score = matcher.ScoreCandidates(
|
||||
source,
|
||||
[candidate],
|
||||
gateway.GetProviderOrder(ProviderCapabilityKind.Streaming)).Single();
|
||||
var reasons = score.Reasons
|
||||
.Prepend("verified_provider_identity")
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return new(
|
||||
new TrackMatchDecision(
|
||||
TrackMatchReviewState.Accepted,
|
||||
candidate.LibraryTrackId,
|
||||
candidate.BackendItemId,
|
||||
1,
|
||||
[score with { Confidence = 1, Reasons = reasons }],
|
||||
reasons,
|
||||
[],
|
||||
scope.PolicyVersion,
|
||||
scope.SourceSnapshotVersion),
|
||||
new Dictionary<Guid, Song> { [candidate.LibraryTrackId] = song },
|
||||
new Dictionary<Guid, IReadOnlyList<Song>> { [candidate.LibraryTrackId] = [song] });
|
||||
var match = DecideMatch(
|
||||
source, scope, [song], localCandidates, manualOverride, providerOrder, effectiveMatcher);
|
||||
if (match.Decision.State is TrackMatchReviewState.Accepted or TrackMatchReviewState.Pinned)
|
||||
return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private PlayableTrackMatch DecideMatch(
|
||||
ExternalTrackMatchSnapshot source,
|
||||
TrackMatchScope scope,
|
||||
IReadOnlyList<Song> songs,
|
||||
IReadOnlyList<LocalTrackMatchCandidate> localCandidates,
|
||||
ScopedTrackMatchOverride? manualOverride,
|
||||
IReadOnlyList<string> providerOrder,
|
||||
TrackMatchDecisionEngine effectiveMatcher)
|
||||
{
|
||||
var groups = GroupEquivalent(songs, scope, providerOrder);
|
||||
return new(
|
||||
effectiveMatcher.Decide(
|
||||
scope,
|
||||
source,
|
||||
localCandidates.Concat(songs.Select(song => ToCandidate(song, scope))).ToArray(),
|
||||
manualOverride,
|
||||
providerOrder),
|
||||
songs.ToDictionary(song => CandidateId(song.ExternalProvider!, song.ExternalId!)),
|
||||
groups.SelectMany(group => group.Select(song => (
|
||||
Id: CandidateId(song.ExternalProvider!, song.ExternalId!), Group: (IReadOnlyList<Song>)group)))
|
||||
.ToDictionary(item => item.Id, item => item.Group));
|
||||
}
|
||||
|
||||
public bool CanUseProvider(string? providerId)
|
||||
{
|
||||
var normalized = ExternalTrackPlaybackPolicy.Normalize(providerId);
|
||||
@@ -175,10 +169,22 @@ public sealed class PlaylistPlayableSearchService(
|
||||
.Any(provider => ExternalTrackPlaybackPolicy.Normalize(provider) == normalized);
|
||||
}
|
||||
|
||||
private bool IsPlayable(Song song)
|
||||
public async Task<bool> CanUseProviderAsync(
|
||||
Guid tenantId,
|
||||
string? providerId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = ExternalTrackPlaybackPolicy.Normalize(providerId);
|
||||
return normalized.Length > 0 &&
|
||||
(await ProviderOrderAsync(tenantId, cancellationToken))
|
||||
.Any(provider => ExternalTrackPlaybackPolicy.Normalize(provider) == normalized);
|
||||
}
|
||||
|
||||
private static bool IsPlayable(Song song, IReadOnlyList<string> providerOrder)
|
||||
{
|
||||
return ExternalTrackPlaybackPolicy.CanUseForPlayback(song) &&
|
||||
CanUseProvider(song.ExternalProvider);
|
||||
providerOrder.Any(provider => ExternalTrackPlaybackPolicy.Normalize(provider) ==
|
||||
ExternalTrackPlaybackPolicy.Normalize(song.ExternalProvider));
|
||||
}
|
||||
|
||||
private LocalTrackMatchCandidate ToCandidate(Song song, TrackMatchScope scope) => new(
|
||||
@@ -212,9 +218,12 @@ public sealed class PlaylistPlayableSearchService(
|
||||
new(SHA256.HashData(Encoding.UTF8.GetBytes(
|
||||
$"{provider.Trim().ToLowerInvariant()}:{externalId.Trim()}"))[..16]);
|
||||
|
||||
private List<Song[]> GroupEquivalent(IEnumerable<Song> songs, TrackMatchScope scope)
|
||||
private List<Song[]> GroupEquivalent(
|
||||
IEnumerable<Song> songs,
|
||||
TrackMatchScope scope,
|
||||
IReadOnlyList<string> providerOrder)
|
||||
{
|
||||
var order = ProviderRanks();
|
||||
var order = ProviderRanks(providerOrder);
|
||||
var groups = new List<List<Song>>();
|
||||
foreach (var song in songs.OrderBy(song =>
|
||||
order.GetValueOrDefault(
|
||||
@@ -234,14 +243,37 @@ public sealed class PlaylistPlayableSearchService(
|
||||
return groups.Select(group => group.ToArray()).ToList();
|
||||
}
|
||||
|
||||
private Dictionary<string, int> ProviderRanks() =>
|
||||
gateway.GetProviderOrder(ProviderCapabilityKind.Streaming)
|
||||
private static Dictionary<string, int> ProviderRanks(IReadOnlyList<string> providerOrder) =>
|
||||
providerOrder
|
||||
.Select((provider, index) => (
|
||||
Provider: ExternalTrackPlaybackPolicy.Normalize(provider),
|
||||
Index: index))
|
||||
.GroupBy(item => item.Provider, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Min(item => item.Index), StringComparer.Ordinal);
|
||||
|
||||
private async Task<IReadOnlyList<string>> ProviderOrderAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken) => effectivePolicies == null
|
||||
? gateway.GetProviderOrder(ProviderCapabilityKind.Streaming)
|
||||
: (await effectivePolicies.ResolveAsync(tenantId, cancellationToken))
|
||||
.ApplyProviderAvailability(
|
||||
ProviderCapabilityKind.Streaming,
|
||||
gateway.GetProviderOrder(ProviderCapabilityKind.Streaming));
|
||||
|
||||
private async Task<(IReadOnlyList<string> ProviderOrder, TrackMatchDecisionEngine Matcher)> MatchingPolicyAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (effectivePolicies == null)
|
||||
return (gateway.GetProviderOrder(ProviderCapabilityKind.Streaming), matcher);
|
||||
var policy = await effectivePolicies.ResolveAsync(tenantId, cancellationToken);
|
||||
return (
|
||||
policy.ApplyProviderAvailability(
|
||||
ProviderCapabilityKind.Streaming,
|
||||
gateway.GetProviderOrder(ProviderCapabilityKind.Streaming)),
|
||||
matcher.WithLocalPriorityWindow(policy.LocalPreferenceWindow));
|
||||
}
|
||||
|
||||
private async Task<AllstarrPrincipal?> ResolvePrincipalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_principal != null)
|
||||
|
||||
@@ -188,7 +188,9 @@
|
||||
"Enabled": true,
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"SourceId": "musicbrainz",
|
||||
"BaseUrl": "https://musicbrainz.org/ws/2",
|
||||
"RateLimitMs": 1000
|
||||
"RateLimitMs": 1000,
|
||||
"AuthorizedUserAgentOverride": ""
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
- host.docker.internal:host-gateway
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
Release__Profile: "${ALLSTARR_RELEASE_PROFILE:-core}"
|
||||
Admin__Containerized: 'true'
|
||||
Admin__ContainerGateway: auto
|
||||
Storage__Provider: Postgres
|
||||
|
||||
+4
-1
@@ -6,6 +6,9 @@ User and operator guides describe shipped behavior. Contributor assessments expl
|
||||
|
||||
- [User guide](user-guide.md): dashboard map, setup order, imports, playlists, cache, and Intelligence.
|
||||
- [Architecture overview](architecture/overview.md): runtime boundaries and code ownership.
|
||||
- [Unified music service plan](architecture/unified-music-service-plan.md): accepted primary release journeys, canonical catalog direction, keep/modify/shelve decisions, and implementation gates.
|
||||
- [Jellyfin and provider routing plan](architecture/jellyfin-provider-routing-plan.md): accepted first-use-case matching, route order, audio quality, fallback, and live qualification contract.
|
||||
- [Music ecosystem reference ledger](architecture/reference-projects.md): pinned upstream projects, reusable lessons, license boundaries, and rejected ideas.
|
||||
- [Configuration](operations/configuration.md): deployment-owned values, durable settings, and secrets.
|
||||
- [Deployment profiles](operations/deployment-profiles.md): install, update, optional services, backup, and restore.
|
||||
- [Storage](operations/storage.md): PostgreSQL ownership, migration, backup, and recovery.
|
||||
@@ -30,7 +33,7 @@ User and operator guides describe shipped behavior. Contributor assessments expl
|
||||
|
||||
## Documentation rules
|
||||
|
||||
1. Describe current behavior only.
|
||||
1. User and operator guides describe current behavior only. Contributor plans must lead with a visible status and must not read like shipped setup instructions.
|
||||
2. Link to the owning code instead of duplicating long lists that can drift.
|
||||
3. Keep deployment choices in operator guides and unfinished work out of public user documentation.
|
||||
4. Do not document SQLite, Redis, Valkey, AIO images, Compose overlays, bundled extension registries, or automatic legacy-state conversion as supported runtime features.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Jellyfin and provider routing plan
|
||||
|
||||
Status: accepted first-use-case plan; working-tree behavior still requires live qualification.
|
||||
Decision date: 2026-09-14.
|
||||
|
||||
This document is the component-level routing contract and an intermediate live-qualification milestone. Its earlier narrow product scope is superseded by the proposed [unified music service plan](unified-music-service-plan.md); the matching, local-first routing, quality, fallback, and qualification rules below remain applicable.
|
||||
|
||||
## Routing qualification target
|
||||
|
||||
The first qualified routing deployment is deliberately narrow:
|
||||
|
||||
- Jellyfin is the native library and authentication backend.
|
||||
- Imported provider playlists retain every source row and source order.
|
||||
- Each source track may resolve to one accepted local Jellyfin item and several accepted external provider identities.
|
||||
- Playback prefers an accepted local Jellyfin item. If no local item is selected, external playback first considers routes that meet the requested quality tier, then follows the streaming-provider order from Settings within that tier. Lower-tier fallback requires the explicit downgrade policy.
|
||||
- The actual serving provider is recorded after the stream opens and is exposed in diagnostics and song details.
|
||||
|
||||
BrainzMash, SkyHook, a MusicBrainz mirror, a Lidarr metadata service, and a merged global artist/discography catalog are not required to qualify this component milestone. The existing bounded MusicBrainz lookup may remain supporting evidence for a recording, but live metadata is never required for routing or playback.
|
||||
|
||||
## One deterministic decision
|
||||
|
||||
Matching and routing are separate decisions.
|
||||
|
||||
### 1. Establish recording identity
|
||||
|
||||
1. Apply an authoritative manual pin or rejection first.
|
||||
2. Score local and external candidates using stable IDs when available, then title, full artist credits, album context, duration, explicitness, and semantic version tags.
|
||||
3. Reject clean/explicit, live/studio, remix/original, instrumental/vocal, or materially different-duration conflicts even when the names resemble each other.
|
||||
4. A candidate is playable automatically only after it independently meets the acceptance threshold and artist-evidence requirement.
|
||||
5. For each provider, retain the closest accepted candidate for the source recording. Lower-scoring candidates remain review evidence, not fallback routes.
|
||||
|
||||
Confidence establishes whether a candidate is the same recording. Provider preference never adds confidence or turns a tentative candidate into a playable route.
|
||||
|
||||
### 2. Order accepted routes
|
||||
|
||||
1. An accepted local Jellyfin candidate wins.
|
||||
2. Otherwise, resolve the best representation each accepted, authorized external route can actually provide for its account.
|
||||
3. Form the candidate tier from routes satisfying the requested normalized quality tier, then order that tier exactly by `Providers:StreamingOrder`.
|
||||
4. Try the first eligible, healthy, authorized route in that tier.
|
||||
5. Before returning any audio, advance to the next accepted route after not-found, unavailable, rate-limit, incompatible-media, or bounded transient failure.
|
||||
6. If the satisfying tier is exhausted and `Allow quality downgrade` is enabled, descend through lower tiers while preserving provider order within each tier.
|
||||
7. Stop on authorization, forbidden, cancellation, policy, or permanent failures.
|
||||
8. Once bytes have been committed, never splice another provider or encoding into that response. A new playback request may select another route.
|
||||
|
||||
The source catalog provider does not own playback. For example, a Spotify playlist entry may play from Jellyfin, Apple Music, Deezer, or Qobuz without changing its playlist position or stable Allstarr identity.
|
||||
|
||||
## Audio quality rule
|
||||
|
||||
Local remains the outer decision. For external routes, target-quality eligibility is resolved before provider order; provider order decides among routes in the same normalized tier.
|
||||
|
||||
- `Audio:Quality` supplies one shared target, and the downgrade policy controls whether lower tiers are allowed after satisfying routes are exhausted.
|
||||
- A client bandwidth or codec request may lower that target but may not silently raise it.
|
||||
- Each provider adapter requests the closest representation it supports at or below the effective target.
|
||||
- Quality tiers normalize lossless status, codec, bit depth, sample rate, channel layout, and bitrate rather than comparing one raw bitrate number across unlike formats.
|
||||
- A provider/account route that cannot meet the requested tier is held for explicit lower-tier fallback; it does not outrank a later configured provider that meets the tier.
|
||||
- Provider order remains deterministic among routes meeting the same tier.
|
||||
- Capability ceilings are account-scoped and verified from opened media. Free, trial, premium, regional, and gateway accounts for the same provider may differ.
|
||||
|
||||
Deezer ARL is a credential mechanism, not a quality tier. Probe the connected account and region, then record an account-scoped observed quality envelope from validated opened media. A free account may provide full-length playback at a low tier, while another ARL may expose a higher tier. Never infer or globally clamp Deezer quality from the presence of an ARL alone. Local Jellyfin remains first when its match is accepted.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Existing owner |
|
||||
| --- | --- |
|
||||
| Candidate scoring, version safety, and manual authority | `Core/Matching/TrackMatchDecisionEngine` and `TrackMatchCommandService` |
|
||||
| Provider/account eligibility and configured order | `Core/Routing/ProviderRouter` |
|
||||
| Pre-response stream failover and serving-provider observation | `Core/Protocols/ProtocolProviderGateway` |
|
||||
| Per-provider quality translation | existing provider streaming/download capability adapters |
|
||||
| Source playlist order and local/external projection | `Core/Playlists/` |
|
||||
| Runtime order and quality settings | durable runtime settings and the existing Settings/Integrations UI |
|
||||
|
||||
Do not add another router, matching service, catalog database, or provider-specific playlist coordinator.
|
||||
|
||||
## Required qualification
|
||||
|
||||
The use case is complete only after the same build passes all of these against the real deployment:
|
||||
|
||||
1. Sign in through Allstarr with a Jellyfin user and preserve that user's account scope.
|
||||
2. Compare the same requests directly against Jellyfin and through Allstarr. Authentication, native browse, item detail, artwork, playlists, playback metadata, audio bytes, user data, and session responses must match exactly, including unknown fields. Server discovery may replace only `LocalAddress` so clients return through Allstarr.
|
||||
3. Import a provider playlist and retain every entry, including unresolved entries.
|
||||
4. Prove exact, fuzzy, featured-artist, album-edition, clean/explicit, live/remix, and duration-conflict fixtures.
|
||||
5. Prove that two accepted candidates select local Jellyfin even when an external candidate scores higher.
|
||||
6. Prove that multiple accepted external candidates first select routes meeting the requested quality tier, then apply configured provider order within that tier, and only descend after the tier is exhausted when downgrade is enabled.
|
||||
7. Disable or fail each route in turn and verify bounded pre-response fallthrough without losing the stable track/playlist identity.
|
||||
8. Request original, data-saver, lossy, and lossless playback for every configured account; verify advertised capability, requested representation, actual opened media, full-length behavior, ranges, and downgrade decisions. For Deezer, test the supplied ARL rather than assuming its tier, and cover low- and high-quality account envelopes with deterministic fixtures.
|
||||
9. Verify range start, seek continuation, cancellation, empty body, HTML/error body, expired lease, retry, and the client's three-second startup budget.
|
||||
10. Verify the serving provider appears in the response header, playback observation, activity detail, and song information without provider text in the canonical title.
|
||||
11. Verify private/global account authorization, disabled/revoked accounts, cache ownership, and two-user isolation.
|
||||
12. Rematch automatically created decisions with the current algorithm while preserving manual pins and rejections.
|
||||
|
||||
Focused unit and protocol tests run before deployment. Live qualification records the exact source commit, image digest, Jellyfin version, provider order, quality target, client/version, timings, and failures. A missing credential or unavailable provider is reported as not qualified, not counted as a pass.
|
||||
|
||||
## Deferred from this component milestone
|
||||
|
||||
- BrainzMash or another pooled MusicBrainz service.
|
||||
- A bundled or required MusicBrainz mirror.
|
||||
- Provider-neutral merged artist and global discography pages.
|
||||
- A second SkyHook/Lidarr Metadata API dialect.
|
||||
- Mandatory Lidarr or AudioMuse.
|
||||
- Searching or fuzzy matching after the listener presses Play.
|
||||
|
||||
These are addressed separately by the [unified music service plan](unified-music-service-plan.md) only after the Jellyfin-plus-provider route is stable and measured. The [reference ledger](reference-projects.md) remains research evidence, not a dependency list.
|
||||
@@ -71,14 +71,48 @@ Built-in and extension capabilities meet at `ProviderRegistry`. Extension IDs ma
|
||||
|
||||
`ProviderAccountOwnership.OwnedBy` is the shared management predicate: a private account belongs to its tenant/user owner; a global account remains managed by its recorded creator. Global records retain null tenant/user/library scope, so sharing needs no duplicate account or secret store. Audience changes rebind the encrypted secret in the same database transaction and invalidate account discovery caches. Routing distinguishes management from consumption: eligible shared capabilities may serve other users, but personal capabilities stay creator-only unless explicit operator policy permits sharing. The owner’s private account is preferred, then their own shared account, before other eligible library/global accounts. Administrator-assigned private accounts cannot be reshared by the recipient or reclaimed by the original creator.
|
||||
|
||||
## Native-server compatibility
|
||||
|
||||
Jellyfin and Subsonic/OpenSubsonic remain the behavioral and data authorities for native objects. Allstarr relays native authentication, browse, item detail, artwork, playlists, playback metadata, streams, user data, and session traffic without reshaping their responses. Unknown upstream fields are part of this contract and pass through unchanged.
|
||||
|
||||
Allstarr changes a native response only when a documented feature requires it: external search results, provider-neutral matching and routes, virtual or linked playlists, external playback, lyrics fallback, music-only surface enforcement, or the proxy address used during server discovery. Each changed surface must preserve native rows and identities, declare its added or filtered fields, and have a direct-server-versus-Allstarr parity test. A new interception without that contract is a regression.
|
||||
|
||||
## Track identity and matching
|
||||
|
||||
`TrackIdentityService`, backend library indexing, persisted provider routes, and the playlist orchestration layer are the shared path. Accepted decisions are reusable by automatic matching, interactive matching, synchronization, playback, and event projections. Cross-provider selection compares raw confidence, then applies the local and configured playback-provider priority windows; preference never changes the recorded confidence or acceptance threshold. Matching-algorithm changes enqueue owner-scoped `track-match.rematch-all` jobs that replace stale automatic decisions in bounded batches while preserving manual authority and append-only history. Playlist refresh and materialization run through durable playlist links and the `playlist.materialize` job; there is no provider-specific matching coordinator. A one-time import reuses its first published source snapshot and no longer requires the source account for later projection or rebuilds. Keep-all retention fans resolved external routes into idempotent `playlist.retain-track` jobs, reauthorizes download accounts for the exact owner and library at execution time, and publishes verified files through the managed-file owner.
|
||||
`TrackIdentityService`, backend library indexing, persisted provider routes, and the playlist orchestration layer are the shared path. Accepted decisions are reusable by automatic matching, interactive matching, synchronization, playback, and event projections. Candidates that satisfy confidence and artist-evidence requirements are selected by local-first/configured streaming priority, not relative confidence windows. Only tentative selection retains preference windows. Cached provider reuse passes through the same decision engine with current local candidates and rejections; it does not force acceptance or overwrite confidence. Matching-algorithm changes enqueue owner-scoped `track-match.rematch-all` jobs that replace stale automatic decisions in bounded batches while preserving manual authority and append-only history. Playlist refresh and materialization run through durable playlist links and the `playlist.materialize` job; there is no provider-specific matching coordinator. A one-time import reuses its first published source snapshot and no longer requires the source account for later projection or rebuilds. Keep-all retention fans resolved external routes into idempotent `playlist.retain-track` jobs, reauthorizes download accounts for the exact owner and library at execution time, and publishes verified files through the managed-file owner.
|
||||
|
||||
## Canonical catalog ingestion
|
||||
|
||||
`MusicBrainzService` is the single bounded client for MusicBrainz-compatible
|
||||
catalog sources. Public MusicBrainz and BrainzMash use the same `/ws/2`
|
||||
contract and feed the same validation, ingestion, matching, and reconciliation
|
||||
path. A deployment selects one endpoint without changing catalog semantics;
|
||||
source IDs and revisions keep caches and provenance separate.
|
||||
`MusicBrainzCatalogIngestService` turns a validated source hierarchy into
|
||||
tenant-scoped artists, release groups, editions, release tracks, recordings,
|
||||
and ordered credits in one serializable PostgreSQL transaction.
|
||||
`CanonicalCatalogEvidenceStore` remains the only writer for external aliases and
|
||||
source-stamped facts, whether called independently or inside graph ingestion.
|
||||
Repeated source payloads preserve IDs and do not create duplicate facts.
|
||||
`MusicBrainzCatalogDiscoveryJobHandler` expands one known recording into a
|
||||
validated, deduplicated set of no more than 50 editions. It enqueues one
|
||||
idempotent release-refresh job per edition; each refresh fetches a bounded
|
||||
hierarchy and passes it to the ingester through the existing durable worker.
|
||||
Metadata discovery, refresh, and reconciliation run outside playback; a source
|
||||
outage never invalidates an already accepted route.
|
||||
|
||||
## External playback selection
|
||||
|
||||
`ProtocolProviderGateway.OpenStreamAsync` owns both protocol surfaces' external selection. The existing `ProviderRouter` authorizes candidates and translates only verified canonical identities. Authorized managed-cache hits are checked before remote opens; `ManagedTrackCacheService` publishes remote bytes under the actual serving provider/track. Controllers no longer open a warm external file before routing, nor fall through to legacy global credentials after an actor-scoped route miss.
|
||||
|
||||
`EffectiveProviderPolicyResolver` reads provider order, disabled providers, audio quality, and local-preference policy for one tenant in one durable-settings query and returns an immutable snapshot. Authenticated protocol search, playlist discovery, matching, projection, lyrics, activity, playback selection, and admin presentation use that snapshot. Process configuration is only the bootstrap fallback and is never rewritten from tenant policy. Provider-order keys and defaults are defined once in `ProviderOrderPolicyCatalog`. A provider disabled in the tenant snapshot cannot be restored merely because it was the catalog source of the requested track.
|
||||
|
||||
Protocol playback and managed downloads pass the shared tenant quality ceiling through the provider contract. A protocol client's explicit bandwidth or codec constraint may lower that request, but an unspecified client quality no longer falls back to a process-wide provider setting.
|
||||
|
||||
Completed streaming-cache references are keyed by tenant, resolved provider account, library, effective quality, provider, and track. The shared gateway authorizes the route before consulting that reference. Physical audio bytes may occupy the same managed cache root, but a mapping from one private account or tenant cannot authorize another listener.
|
||||
|
||||
Provider priority is evaluated one candidate at a time: an available cache entry for a lower-priority provider cannot jump ahead of a higher-priority remote route. The opened stream carries a redacted reason code describing priority or fallback, account-resolution class, and cache or remote delivery. That reason is retained in the scoped playback observation and exposed to administrators without account identifiers.
|
||||
|
||||
Remote opening honors the protocol deadline and the lease's single-retry policy. The gateway verifies media headers and prefetches one byte before returning a response, allowing bounded pre-response failover without buffering the song. Cancellation and authorization failures stop selection. Native backend streaming does not enter this path.
|
||||
|
||||
The bounded `PlaybackDeliveryActivityStore` retains disposable, one-hour stream-open observations, keyed by actor, device and requested item, with exact backend/library/quality keys for byte-range continuation. These are not durable sessions or match decisions. Durable playback signals capture the observation at ingestion; listening events can replace provisional catalog attribution with confirmed source attribution. No credential or media URL is stored in the observation. See [client compatibility](../operations/client-compatibility.md#external-song-labels-and-playback-sources) for presentation and device/seek limitations.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Music ecosystem reference ledger
|
||||
|
||||
Status: contributor research, not shipped behavior.
|
||||
Last audited: 2026-09-14.
|
||||
|
||||
This ledger records which upstream projects informed Allstarr, the exact revisions inspected, and the boundary between a useful idea and a dependency. It exists to prevent repeated archaeology, accidental code copying, and architecture-by-name-dropping. Re-audit a project before relying on a changed contract.
|
||||
|
||||
The audit clones are temporary working copies outside this repository. Allstarr does not vendor them, add them as submodules, or depend on their branch heads.
|
||||
|
||||
## Catalog and identity
|
||||
|
||||
| Project and audited revision | License found | What it proves | Allstarr decision |
|
||||
| --- | --- | --- | --- |
|
||||
| [MusicBrainz Docker `c8a225f`](https://github.com/metabrainz/musicbrainz-docker/tree/c8a225fab3ea3a4650d8b1e0fab41d98878adc29) | No repository-level license file detected; underlying components and data have their own terms | A replicated MusicBrainz deployment is operationally substantial and does not repair missing upstream metadata | Do not require or deploy it for the first Jellyfin-plus-provider use case. |
|
||||
| [BrainzMash hearing-aid `c25bd6e`](https://github.com/statichum/brainzmash-hearring-aid/tree/c25bd6e8816592af33cac65c42dff6a35b9e0566) | No repository-level license detected | A community pool exposes a Lidarr-shaped API and a separately approved read-only MusicBrainz-compatible `/ws/2` front door | Use `https://api.brainzmash.cc/ws/2` as the selected canonical upstream after its operator approves Allstarr's honest user agent. Do not copy or deploy the pool software as part of Allstarr. |
|
||||
| [BrainzMash bootstrap `0db5710`](https://github.com/statichum/brainzmash-bootstrap/tree/0db5710e9e983226fb88e0d30c3ccfd4eb1022f8) | No repository-level license detected | Shows how a contributor node is joined to the pool | Do not use it for an Allstarr-supported deployment. It tracks mutable branches/images, publishes internal services, stores secrets in generated configuration, and lacks the supply-chain and rollback controls required here. |
|
||||
| [DroppedNeedle `5a337fc`](https://github.com/DroppedNeedle/DroppedNeedle/tree/5a337fc94b3b2edb0b00742dbccce605d93ee1ef) | AGPL-3.0 | It demonstrates separate recording/release identity, manual review, support-only fingerprint evidence, and the approved BrainzMash `/ws/2` consumer contract | Retain those independent matching, source-activation, privacy-disclosure, and client-behavior lessons without copying its catalog architecture or identity. |
|
||||
| [Aurral `5fb0f3e`](https://github.com/lklynet/aurral/tree/5fb0f3e8c371de1fc5635594c7761a3e05f0ac0d) | MIT | Its Lidarr-shaped BrainzMash client demonstrates bounded concurrency, single-flight requests, caching, health state, and short retry budgets | Reuse resilience lessons in Allstarr's existing MusicBrainz client and canonical catalog owner, but do not add Aurral's second metadata dialect. |
|
||||
| [Lidarr Metadata `e458ca6`](https://github.com/Lidarr/LidarrAPI.Metadata/tree/e458ca6393236a35411d28450e405fc7efb4c505) | No repository-level license detected | Shows how Lidarr builds rich metadata responses from a MusicBrainz mirror, search indexes, caches, and auxiliary metadata sources | Use as a data-shape and operational reference only. Do not add a second first-release catalog dialect or copy its implementation without permission. |
|
||||
| [MusicBrainz Picard `efb6972`](https://github.com/metabrainz/picard/tree/efb6972b2d1db0c1a823fd5b379ea8c872b52f4d) | GPL-2.0-or-later | MusicBrainz IDs, release-track identity, file tags, and AcoustID evidence have distinct roles | Preserve those boundaries in Allstarr's independent matcher. A fingerprint supports identity; it does not erase version, edition, or manual-authority conflicts. |
|
||||
|
||||
## Playback, routing, and library ownership
|
||||
|
||||
| Project and audited revision | License found | What it proves | Allstarr decision |
|
||||
| --- | --- | --- | --- |
|
||||
| [Music Assistant server `ee38f52`](https://github.com/music-assistant/server/tree/ee38f522a1eb07c5d359566cc8c171538c89b5e2) | Apache-2.0 | A library entity can own mappings to several provider instances without making any provider ID canonical | Keep one Allstarr recording with multiple authorized routes. Let Settings order select playback only after each route independently passes match acceptance. |
|
||||
| [AudioMuse `e78c87d`](https://github.com/NeptuneHub/AudioMuse-AI/tree/e78c87dccc2afbfb22b3d3264a8b36a50f3e9d3f) | AGPL-3.0 | Local-server identity, content-derived evidence, and recommendation features can be separated | AGPL reuse is acceptable with the same provenance, notice, source-offer, and modification records. Use AudioMuse only as optional evidence/enrichment through the existing Intelligence boundary. Never make it the catalog or routing authority. |
|
||||
| [DroppedNeedle `5a337fc`](https://github.com/DroppedNeedle/DroppedNeedle/tree/5a337fc94b3b2edb0b00742dbccce605d93ee1ef) | AGPL-3.0 | Acquired files need verification, quarantine, provenance, atomic catalog admission, and eventual native-library reconciliation | Apply those lessons to Allstarr's existing download/keep pipeline instead of introducing a second library manager. Lidarr remains optional. |
|
||||
|
||||
## Listening and scrobbling
|
||||
|
||||
| Project and audited revision | License found | What it proves | Allstarr decision |
|
||||
| --- | --- | --- | --- |
|
||||
| [Multi-Scrobbler `fa2778f`](https://github.com/FoxxMD/multi-scrobbler/tree/fa2778f3b529a10b6996ad67e80d8bc27f825b12) | MIT | Many playback sources can feed selected per-user destinations through normalized play events, duplicate detection, durable ingress/dead queues, retry state, now-playing updates, health, and metrics | Use its event-lifecycle and test matrix as a reference for Allstarr playback observations and scrobble delivery. Allstarr keeps its PostgreSQL durable-job/outbox owner; it does not embed Multi-Scrobbler or create another queue. |
|
||||
| [DroppedNeedle `5a337fc`](https://github.com/DroppedNeedle/DroppedNeedle/tree/5a337fc94b3b2edb0b00742dbccce605d93ee1ef) | AGPL-3.0 | Listener identity, app passwords, per-user discovery accounts, live events, and protocol playback can coexist without making admin credentials global | Keep listener/account isolation in Allstarr's existing tenant/user/account scopes. Provider sharing is an explicit scope choice, never inferred from an admin login. |
|
||||
|
||||
Multi-Scrobbler is useful for listening-state mechanics, not for canonical music identity. Koito, Maloja, ListenBrainz, Last.fm, Jellyfin, and Subsonic adapters in that project broaden the behavioral fixture matrix; they do not justify adding all of those services as Allstarr dependencies.
|
||||
|
||||
## Ideas deliberately rejected
|
||||
|
||||
- A provider ID as the primary artist, album, or recording ID.
|
||||
- A separate canonical database owned by Lidarr, AudioMuse, a scrobbler, or each provider adapter.
|
||||
- Hidden fallback from one metadata operator to another after a failure.
|
||||
- Changing a configured catalog endpoint during startup migration.
|
||||
- Copying an unlicensed implementation because its public HTTP responses can be observed.
|
||||
- Executing a remote bootstrap script from a mutable branch.
|
||||
- Using mutable container tags for a catalog release.
|
||||
- Publishing PostgreSQL, cache, Solr, raw MusicBrainz, or Lidarr Metadata ports to an untrusted network.
|
||||
- Treating transient failure as authoritative absence or permitting stale work from an old source generation to overwrite a new source.
|
||||
- Concluding that a recording matches solely from a shared MusicBrainz ID, ISRC, title, or fingerprint when duration/version/manual evidence conflicts.
|
||||
|
||||
## Re-audit checklist
|
||||
|
||||
Before implementing or upgrading a dependency informed by this ledger:
|
||||
|
||||
1. Pin the exact upstream revision, release, and image digests.
|
||||
2. Re-read the repository license and every data/API term involved.
|
||||
3. Capture sanitized request, response, paging, redirect, and error fixtures for only the routes Allstarr will call.
|
||||
4. Compare the upstream contract with Allstarr's existing owner before adding a new abstraction.
|
||||
5. Record what changed in this ledger and the relevant architecture or operation document.
|
||||
6. Keep temporary clones and generated audit output outside the repository.
|
||||
@@ -0,0 +1,464 @@
|
||||
# Unified music service product and release plan
|
||||
|
||||
Status: accepted product direction and engineering plan; not a shipped-support contract.
|
||||
Decision date: 2026-09-14.
|
||||
|
||||
## Product outcome
|
||||
|
||||
Allstarr should make a music library hosted by Jellyfin or a Subsonic/OpenSubsonic server such as Navidrome feel like one complete, self-hosted streaming service. A listener searches, browses, saves, and plays music without first choosing a provider. The deployment's selected native backend remains the library, authentication, transcoding, and client-compatibility foundation. Allstarr adds the catalog, identity, playlist, route, account, acquisition, and observation layers needed to fill gaps from authorized external providers. One deployment exposes one selected native protocol surface; the canonical core beneath both protocol adapters is shared.
|
||||
|
||||
The product must present **one musical entity with several playable routes**, not several provider-branded copies of the same song. Provider names are operational facts shown in route details, now playing, diagnostics, and account controls. They are not the primary identity of a recording.
|
||||
|
||||
The first public release is complete only when the following journeys work together:
|
||||
|
||||
1. A user signs in through the configured Jellyfin or Subsonic/OpenSubsonic backend and sees the native library unchanged.
|
||||
2. Search returns one coherent artist, release, and recording view across local and external availability.
|
||||
3. A user imports a playlist once or links it for refresh; every source row remains visible and in order.
|
||||
4. Matching attaches accepted local and external routes to the same recording while preserving manual authority.
|
||||
5. Playback uses an accepted local item first, then external routes that meet the requested quality tier in configured provider order, with bounded pre-response fallback and an explicit downgrade policy.
|
||||
6. Keeping an external recording eventually produces an observed, reconciled native-backend item and changes future playback to local.
|
||||
7. Settings and account scope apply consistently to UI requests, jobs, caches, and playback.
|
||||
8. PostgreSQL preserves the catalog graph, ownership, decisions, jobs, and upgrade path across restarts and releases.
|
||||
|
||||
Intelligence and recommendations are separate milestones. Extension distribution and both native protocol surfaces are part of the release plan and must not make the primary journeys slower or less reliable.
|
||||
|
||||
## Primary release pillars
|
||||
|
||||
### 1. Canonical catalog and search
|
||||
|
||||
Search is a primary product feature, not a provider result aggregator. It needs enough canonical metadata to describe artists, releases, release tracks, and recordings, then overlays the routes the current user may use.
|
||||
|
||||
Canonical means that Allstarr owns the stable identity. It does not mean that MusicBrainz or BrainzMash already has the music. A native-backend or connected-provider result can create a provisional Allstarr artist, release, release track, or recording immediately. That entity is complete enough to search, browse, match, save, and play while later evidence improves it.
|
||||
|
||||
The current search implementation is a useful federation baseline: its protocol surfaces query the selected native backend and typed provider capabilities, preserve native data, and rank local results well. It does not yet form a unified catalog. Provider results keep provider-shaped IDs, identical recordings are only deduplicated within a provider identity, and external artist browsing remains tied to the provider that produced the result.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Return one stable Allstarr identity for an artist, release, release track, or recording.
|
||||
- Emit provider-neutral canonical artist and release names. Transitional suffixes such as `[AM]`, `[D]`, and `[Q]` must disappear from artist/album identity and navigation once canonical relationships are available.
|
||||
- Keep `[A]` and `[A]/[E]` only as protocol presentation markers for an Allstarr-injected track when a client needs that distinction; they are not stored title text or identity. Show available routes and the actual serving provider in song information and now-playing diagnostics.
|
||||
- Merge provider and native-backend facts without changing an original Jellyfin object or Subsonic/OpenSubsonic identity and field contract when it is passed through natively.
|
||||
- Show availability separately from identity: local, Apple Music, Deezer, Qobuz, or another qualified provider.
|
||||
- Resolve aliases from existing `ext-{provider}-...` IDs during migration so saved clients and playlists do not break.
|
||||
- Search a local PostgreSQL projection first. Refresh catalog facts asynchronously; do not place a public metadata service or fuzzy provider search in the Play request path.
|
||||
- Fan out each listener search to the native backend and every authorized metadata provider within a bounded search deadline. Merge successful provider results into the response even when the selected MusicBrainz-compatible source has no result or is unavailable.
|
||||
- Materialize every previously unknown provider result as a provisional Allstarr entity before returning it. Persist the provider namespace and external ID as an alias, retain the provider payload as source-stamped facts, and attach an authorized playable route when one exists.
|
||||
- Never suppress a provider result merely because it lacks an MBID or ISRC. Exact aliases, compatible identifiers, and accepted match evidence may merge results; uncertain candidates remain separate and reviewable instead of being hidden by an unsafe deduplication.
|
||||
- Continue serving cached catalog facts when the catalog source is stale or unavailable, with observable freshness and refresh failures.
|
||||
- Treat provider catalog search as discovery and route availability, not as canonical truth.
|
||||
|
||||
#### Provider-only identity and reconciliation
|
||||
|
||||
Search follows one identity pipeline for native, MusicBrainz-backed, and provider-only music:
|
||||
|
||||
1. Resolve an exact provider alias to its existing Allstarr entity when one exists.
|
||||
2. Otherwise, use a compatible recording identifier such as ISRC only after version, artist, duration, and explicitness safeguards pass.
|
||||
3. Otherwise, attach the route to an already accepted match when the normal matching policy proves that it is the same recording.
|
||||
4. If none of those checks succeeds, create a new provisional Allstarr entity. The result remains visible and playable; it does not wait for MusicBrainz.
|
||||
5. Store each provider observation as evidence with its source, account/catalog scope, observed time, and refresh state. Provider facts can improve display metadata without becoming identity authority by themselves.
|
||||
6. Reconciliation runs outside playback. A later MusicBrainz result or another provider route enriches the existing Allstarr entity when the evidence is safe. It must retain the Allstarr ID, saved state, playlist membership, manual decisions, and route history.
|
||||
7. A conflict creates a reviewable merge candidate. It must not silently replace or remove either entity.
|
||||
|
||||
A provisional entity is a supported catalog state, not an error or a second-class search result. The app may show that metadata is provider-sourced or still being reconciled, but this state cannot disable normal playback from an authorized route.
|
||||
|
||||
Protocol title markers describe presentation, not identity or routing:
|
||||
|
||||
- `[A]` means the client is seeing an Allstarr-injected object rather than an unchanged native object.
|
||||
- `[A]/[E]` adds the explicit-content marker.
|
||||
- Do not use `[AM]`, `[D]`, `[Q]`, or another provider code in the title. One Allstarr recording may have several routes, and the serving provider can change during fallback.
|
||||
- Show route icons or names in Allstarr song information. Now-playing and playback diagnostics show the provider that actually served the current stream.
|
||||
|
||||
Regression coverage must prove that a provider-only recording with no MBID or ISRC appears in search, receives the same Allstarr ID on repeated searches, opens and plays through its authorized route, survives a MusicBrainz miss or outage, and retains its ID when later reconciled. Cross-provider tests must also prove that exact accepted matches gain multiple routes, ambiguous versions remain separate, unauthorized accounts do not leak availability, and one provider's timeout does not erase successful results from another provider.
|
||||
|
||||
### 2. Playlist ingestion and matching
|
||||
|
||||
Playlist import is a primary feature because it converts a user's intent into a durable, ordered set of recordings. The existing snapshot, source-entry, sync-run, result, membership, one-time import, and linked-refresh model is worth keeping.
|
||||
|
||||
Playlist matching uses metadata, but it must not depend on live global-catalog availability. A source row already supplies provider identity and descriptive evidence. The matcher should identify or create the canonical recording, reuse known routes, and schedule bounded discovery only for missing routes.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Preserve every source row and source order, including unresolved rows.
|
||||
- Attach a source row to one canonical recording and zero or more accepted routes.
|
||||
- Reuse a verified route across playlists without repeating fuzzy discovery.
|
||||
- Preserve manual pins and rejections across refresh, rematch, algorithm upgrades, and provider outages.
|
||||
- Allow one-time imports to stop consulting the source after their frozen snapshot.
|
||||
- Allow linked imports to refresh without replacing user authority or silently dropping rows.
|
||||
- Project the full mapped playlist to listeners. An unresolved row stays visible but unplayable; it is not removed to make completion statistics look better.
|
||||
- Keep source playlist identity separate from the provider ultimately used for playback.
|
||||
|
||||
### 3. Streaming and route selection
|
||||
|
||||
Streaming must consume an already established identity and route set. It must never run fuzzy matching or remote metadata discovery after the listener presses Play.
|
||||
|
||||
Required route order:
|
||||
|
||||
1. An explicit manual route, when its authority policy requires it.
|
||||
2. Any accepted, authorized local native-backend route.
|
||||
3. Determine the best allowed representation each accepted, authorized external route can actually provide for the current account.
|
||||
4. Among routes that satisfy the requested normalized quality tier, follow the user's effective provider order.
|
||||
5. Within the chosen provider, use the closest allowed representation for the target without exceeding a request or bandwidth ceiling.
|
||||
6. Before response bytes are committed, fall through to the next eligible route on bounded availability or media failures.
|
||||
7. After every route satisfying the target tier is exhausted, descend through lower tiers only when `Allow quality downgrade` is enabled, preserving provider order within each tier.
|
||||
|
||||
Confidence answers “is this the same recording?” It does not answer “which provider should play it?” Provider priority and quality never inflate confidence. Once routes are accepted as the same recording, local wins; external quality eligibility forms the candidate tier, and configured provider order decides among candidates in that tier.
|
||||
|
||||
Do not rank providers using one raw bitrate-distance score. Codec, lossless status, bit depth, sample rate, channel layout, and bitrate are normalized into named quality tiers. The target describes the desired tier, while request/client constraints may impose a lower ceiling. Provider/account capability is an eligibility fact, not a preference bonus.
|
||||
|
||||
The existing typed provider router, protocol gateway, streaming leases, response validation, and serving-provider observations are the correct foundation. Complete the range, startup-deadline, cache-ownership, and client-qualification work in those owners instead of adding another router.
|
||||
|
||||
### 4. Settings, accounts, and policy
|
||||
|
||||
Settings are part of the playback contract. A policy is not complete until foreground requests, background jobs, caches, projections, and diagnostics all resolve the same effective value.
|
||||
|
||||
Keep three categories distinct:
|
||||
|
||||
| Category | Examples | Owner |
|
||||
| --- | --- | --- |
|
||||
| Deployment-owned | backend kind and URL, PostgreSQL, bind/security, managed paths, encryption key ring | Compose/environment and operator documentation |
|
||||
| Runtime policy | provider order, quality target, quality-downgrade permission, matching thresholds, retention, schedules | typed durable settings in PostgreSQL |
|
||||
| User/account choice | private or shared provider account, personal playlist source, per-user routing override where allowed | scoped provider accounts and user preferences |
|
||||
|
||||
Required changes:
|
||||
|
||||
- Keep the typed setting catalog, validation, revision checks, transactional writes, audit events, and change notifications.
|
||||
- Replace mutable `IOptions`/configuration projection with immutable effective-policy snapshots consumed by the owning services.
|
||||
- Derive provider choices and UI schema from the typed capability registry and setting catalog; remove the manually duplicated support catalog.
|
||||
- Invalidate only state affected by a setting change instead of purging the complete playback cache for most changes.
|
||||
- Make effective precedence explicit: deployment constraint, tenant default, optional user override, then request constraint.
|
||||
- Resolve and expose an account-scoped quality envelope for every streaming route. A provider name alone must not imply a tier because free, trial, premium, regional, and gateway accounts may return different representations.
|
||||
- Prove that private accounts never leak through a cache hit, job, playlist projection, or fallback. A shared/global account remains an explicit owner-authorized choice.
|
||||
- Keep secret material encrypted and out of settings responses, logs, exports, fixtures, and activity details.
|
||||
|
||||
### 5. PostgreSQL and durable work
|
||||
|
||||
PostgreSQL is the sole durable database and is a release pillar, not an implementation detail. The current schema has strong foundations: tenants and backend identities, scoped encrypted accounts, jobs and outbox, canonical recordings, provider identities, playlist snapshots, match history, audits, health state, managed files, backups, and migrations.
|
||||
|
||||
The catalog graph must expand through the existing storage and identity owners:
|
||||
|
||||
```text
|
||||
Canonical artist
|
||||
└─ release group
|
||||
└─ release/edition
|
||||
└─ release track ──> canonical recording
|
||||
├─ local native-backend item route(s)
|
||||
├─ Apple Music route(s)
|
||||
├─ Deezer route(s)
|
||||
├─ Qobuz route(s)
|
||||
└─ acquired/kept lifecycle
|
||||
```
|
||||
|
||||
Definitions matter:
|
||||
|
||||
- A **recording** is the audio performance identity. Live, studio, remix, instrumental, clean, and explicit recordings remain distinct.
|
||||
- A **release track** places a recording on a specific edition and position. One recording may occur on several releases.
|
||||
- A **route** is an authorized playable representation. It can appear, disappear, or change health without changing recording identity.
|
||||
- A **catalog fact** has source, freshness, and confidence/provenance. A provider payload is not silently copied into permanent truth.
|
||||
|
||||
Required changes:
|
||||
|
||||
- Extend the current canonical-recording/provider-identity graph; do not create a parallel catalog or matching database.
|
||||
- Add canonical artist, release-group, release, and release-track ownership plus aliases for old protocol/provider IDs.
|
||||
- Attach multiple local library items and external identities to a recording, then select among accepted routes at playback time.
|
||||
- Scope downloaded mappings and cached artifacts by the same tenant, user/account, library, and capability rules used at authorization time.
|
||||
- Complete keep/download reconciliation: verified placement, backend scan request, observed native item, canonical attachment, and local-first transition.
|
||||
- Add indexes and paging based on measured search, playlist, and review queries on real PostgreSQL data.
|
||||
- Preserve forward-only migrations and tested backup/restore. Do not squash published migrations merely to reduce file count.
|
||||
- Keep shelved-feature tables intact unless a separately reviewed migration proves safe deletion; shelving UI and runtime registration must not destroy user data.
|
||||
|
||||
### 6. Acquisition and local transition
|
||||
|
||||
Growing the user's local library is a primary product outcome, not an optional download utility. “Keep” is complete only when an external recording becomes an observed native-backend item and the same canonical identity starts routing locally.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Use one durable workflow for an explicit keep, playlist-retention policy, or opt-in favorite action.
|
||||
- Authorize the exact user/account route, download to a bounded workspace, verify media, and place it only in an explicitly owned managed root.
|
||||
- Enrich and tag opportunistically; a metadata outage may delay enrichment but must not corrupt or discard verified media.
|
||||
- Request the selected backend's scan/rescan, observe the resulting native item, attach its local identity, and preserve playlist/search references.
|
||||
- Treat backend refresh as a capability. If a Jellyfin or Subsonic/OpenSubsonic server does not expose an authorized refresh operation, keep the acquisition in an honest `awaiting backend scan` state, show the operator action required, and reconcile by bounded polling after the external/manual scan occurs.
|
||||
- Show queued, downloading, verifying, awaiting scan, available locally, retryable failure, and permanent failure honestly.
|
||||
- Never modify an original backend library file or treat a successful copy/scan request as proof that the selected backend indexed the recording.
|
||||
|
||||
## Cross-cutting release contracts
|
||||
|
||||
The six pillars are not sufficient unless the following contracts hold across all of them.
|
||||
|
||||
### Native-backend identity and client compatibility
|
||||
|
||||
- The configured Jellyfin or Subsonic/OpenSubsonic server remains the authentication and native-library authority. Allstarr maps each authenticated backend identity to an exact tenant/user scope and never substitutes an administrator account for ordinary playback.
|
||||
- Internally, a native item participates in canonical identity and is an accepted local route. At the protocol boundary, a native representative remains preferred: preserve Jellyfin objects and IDs or Subsonic/OpenSubsonic IDs and field semantics when Allstarr has no reason to inject or translate the item.
|
||||
- If a canonical recording has a native representative, project that native item without `[A]`. If it has no native representative, synthesize a stable Allstarr item and use `[A]` or `[A]/[E]` where the client needs a virtual-track marker. Do not append `[J/A]`, `[S/A]`, or provider suffixes to canonical artist and album names.
|
||||
- Define and fixture the virtual-item contract separately for Jellyfin JSON and Subsonic/OpenSubsonic XML/JSON: stable IDs, parent/artist/release navigation, playback URLs, ranges, transcoding parameters, favorites/stars, playlists, scrobbling/now-playing reports, and error shapes.
|
||||
- Maintain an explicit backend/client matrix. A generic HTTP success is not qualification for Musiver, official Jellyfin clients, Navidrome-connected Subsonic clients, or another named client.
|
||||
|
||||
### Artwork and presentation
|
||||
|
||||
- Canonical artists and releases need stable artwork identities independent of the provider currently serving the image.
|
||||
- Prefer an accepted local native-backend image, then cached canonical artwork, then configured external sources. Record provenance and avoid embedding expiring provider URLs in durable objects.
|
||||
- Cache images with bounded size, content-type validation, negative-cache expiry, and tenant/account rules where the source is not public.
|
||||
- Missing artwork degrades to a consistent placeholder and never breaks search, browse, playlist projection, or playback.
|
||||
|
||||
### Favorite, Keep, and playlist membership semantics
|
||||
|
||||
- **Favorite** is a user's preference signal and, only when explicitly configured, may enqueue a Keep action.
|
||||
- **Keep** is an acquisition request with a durable lifecycle; it is not complete until the recording is observed in the selected native backend.
|
||||
- **Playlist membership** expresses ordering/collection intent. Importing or linking a playlist does not silently favorite or download every entry unless the user selects a retention policy.
|
||||
- Removing a favorite, playlist membership, or source link does not delete a managed file implicitly. Unkeep/removal requires an ownership/reference check and an explicit confirmation path.
|
||||
|
||||
### Provider and extension onboarding
|
||||
|
||||
- A user can understand which capability an account supplies: playlist source, metadata, streaming, download, or lyrics. One login must not imply capabilities supplied by a different gateway.
|
||||
- Private versus shared/global scope is selected explicitly, is change-audited, and is testable before saving.
|
||||
- Keep the extension runtime as a release feature for provider capabilities: manifest compatibility, permission declarations, scoped accounts/secrets, package verification, health, disable, and rollback.
|
||||
- Third-party extensions use the same capability registry, durable jobs, routing, cache, and account rules as built-ins. They cannot replace reserved providers or introduce parallel matching/storage owners.
|
||||
- Ship a curated marketplace/control plane where users can browse and search extensions, inspect authorship, source, license, version compatibility, capabilities, permissions, verification status, release notes, and available updates before installation.
|
||||
- Support explicit installation from the marketplace, a package/file, or an approved source URL; manual update with permission/version review; disable; rollback; and uninstall with owned-state checks.
|
||||
- Update discovery is automatic, but installing an update requires user approval. Unattended automatic installation may be considered later only with signed packages, permission-diff blocking, health verification, and automatic rollback.
|
||||
- Provider-specific onboarding uses declarative account/settings fields and bounded authentication callbacks. Arbitrary extension UI injection, extension-owned schedulers outside the durable job system, and unrestricted filesystem/process access remain out of scope.
|
||||
|
||||
### Failure, observability, and recovery
|
||||
|
||||
- Every primary operation reports a stable correlation/job ID, current state, last actionable failure, retry policy, and safe recovery action without exposing secrets or raw provider payloads.
|
||||
- Activity is an operational projection of authoritative events, not a second state store. Its labels must reflect the persisted decision and actual serving route.
|
||||
- Provider health, account eligibility, catalog freshness, route rejection, cache result, fallback reason, and first-byte timing are inspectable by an administrator and appropriately redacted for users.
|
||||
- Playback diagnostics record the requested tier, account-scoped advertised ceiling, actual opened codec/container/bitrate/sample properties when measurable, downgrade decision, and serving route. Never record the credential itself.
|
||||
- Rate limits, backpressure, concurrency, retry budgets, and circuit state are owned centrally per capability/account; a provider outage cannot create unbounded work.
|
||||
- Cancellation and restart are safe at every durable stage. Recovery never turns an unresolved or tentative identity into an accepted route.
|
||||
|
||||
### Upgrade and compatibility
|
||||
|
||||
- Publish the supported upgrade path, required backups, rollback limits, and whether the release accepts an existing database or requires a fresh baseline.
|
||||
- Migrate legacy provider-shaped IDs, playlist links, runtime settings, match authority, and managed-file records transactionally or through resumable durable jobs.
|
||||
- Keep old IDs as aliases for the documented compatibility window; measure alias use before removal.
|
||||
- Test upgrade, interrupted migration, backup, restore, and rollback against a realistic copy of PostgreSQL and managed-file metadata.
|
||||
|
||||
## What is solid, what changes, and what leaves the release
|
||||
|
||||
| Area | Current assessment | Disposition | Required release action |
|
||||
| --- | --- | --- | --- |
|
||||
| Native Jellyfin proxy | Broad auth, browse, art, playlist, favorite, stream, and session coverage | **Keep and qualify** | Qualify Jellyfin 12 and named clients; preserve native objects unchanged |
|
||||
| Native Subsonic/OpenSubsonic proxy | Query/form authentication, XML/JSON, browse/search, art, lyrics, playlists, stars, streaming, and observations are present | **Keep and qualify** | Qualify the supported OpenSubsonic contract against Navidrome and named clients; preserve native identities and semantics |
|
||||
| Canonical recording/provider identity | Already models one recording to many scoped provider identities | **Keep and extend** | Add artist/release structure, aliases, and stable protocol projection |
|
||||
| Typed capability registry and router | Correct capability/account/priority boundary | **Keep** | Make it the only provider selection owner |
|
||||
| Match decision engine | Strong evidence, version safety, manual pins/rejections, versioned rematch | **Keep** | Freeze semantics, add catalog reuse, split orchestration only after regressions exist |
|
||||
| Playlist snapshots and projections | Durable source intent, ordering, refresh, and route overlay | **Keep** | Consolidate legacy imports and finish listener projection/reconciliation |
|
||||
| Protocol provider gateway | Correct home for leases, pre-response fallback, and serving-provider observation | **Keep** | Finish stable IDs, ranges, cache scope, and startup timing |
|
||||
| Durable jobs, leases, retries, and outbox | Correct home for retryable state changes | **Keep** | Move any remaining detached or controller-owned long work here |
|
||||
| Durable settings catalog | Typed, validated, audited, transactional | **Keep** | Replace mutable runtime projection and duplicated schema/support lists |
|
||||
| Provider account and secret scope | Strong storage model and encrypted references | **Keep** | Prove two-user foreground, job, cache, and playlist isolation |
|
||||
| Managed-file safety and full backup/restore | Clear owned-path and recovery boundaries | **Keep** | Route every keep/acquisition entry point through the same owner |
|
||||
| Federated search | Useful concurrent local/external baseline | **Modify substantially** | Canonical merge, stable IDs, catalog projection, unified browse |
|
||||
| `TrackMatchCommandService` | Valuable behavior concentrated in an oversized owner | **Clean after behavior freeze** | Separate commands, queries/projections, and discovery without duplicating rules |
|
||||
| Settings/admin controllers | Functional but mix schema, transfer, restart, cache, diagnostics, and policy | **Split and reduce** | Keep normal policy/account flows; move operator recovery out of ordinary UI |
|
||||
| Legacy metadata aggregation | Coexists with typed metadata capability gateway | **Consolidate/delete** | Move remaining callers, add parity tests, remove duplicate owner |
|
||||
| Legacy download-and-stream path | Coexists with typed leases and durable downloads | **Consolidate/delete** | Preserve range/failure fixtures, migrate callers, remove old fallback |
|
||||
| Old Spotify playlist setting API | Coexists with durable playlist links | **Delete after migration** | Migrate active state and keep one playlist-link API |
|
||||
| Registered legacy M3U playlist sync service | No production caller found in the prior audit; it is separate from the active Subsonic/OpenSubsonic protocol surface | **Delete if final reference check agrees** | Remove only the orphan registration, code, and obsolete tests/docs without reducing protocol playlist support |
|
||||
| Manual provider support catalog | Duplicates the typed capability registry | **Delete** | Generate support/settings presentation from registry descriptors |
|
||||
| Docker-socket restart and environment export UI | Deployment-specific and broadens the admin controller | **Shelve from product UI** | Keep documented operator CLI/Compose procedures |
|
||||
| Selective state-transfer UI | Large advanced maintenance surface | **Shelve** | Retain and qualify full backup/restore first |
|
||||
| Intelligence, history imports, recommendations, and AudioMuse workbench | Large, high-churn feature family outside the primary journey | **Shelve from release composition** | Stop routes/jobs/entry points in release builds while preserving data and explicit development access |
|
||||
| Extension marketplace and control plane | Sophisticated runtime exists, but distribution, manual updates, and a qualified package journey are incomplete | **Keep and finish** | Ship curated discovery, permission review, install, update detection, manual update, health, disable, rollback, and uninstall |
|
||||
| Lyrics and scrobble delivery | Useful supporting features that can degrade independently | **Keep if isolated** | Do not let failures block browse, playlist import, or playback |
|
||||
| Lidarr integration | Useful possible acquisition/organization adapter, not identity authority | **Shelve** | Reconsider after direct keep-to-native-backend reconciliation is complete |
|
||||
|
||||
## Canonical metadata dependency
|
||||
|
||||
Search and playlist matching both benefit from canonical metadata, but they use it differently:
|
||||
|
||||
| Consumer | Needs canonical metadata for | Must still work when metadata refresh is unavailable? |
|
||||
| --- | --- | --- |
|
||||
| Search/browse | merged artists, release hierarchy, deduplication, credits, editions, stable IDs | Yes, from the last local catalog projection; freshness may be shown |
|
||||
| Playlist matching | identity evidence and reuse across imports | Yes; source metadata and known identities can still resolve or remain reviewable |
|
||||
| Playback | nothing beyond the persisted canonical ID and accepted routes | Yes; playback must never depend on live metadata lookup |
|
||||
| Acquisition | tags and post-download reconciliation evidence | Yes; enrichment may retry without losing the acquired artifact |
|
||||
|
||||
**Catalog-source decision:** public MusicBrainz and BrainzMash are equivalent `/ws/2` catalog sources behind `MusicBrainzService`. Both feed the same validation, caching, provenance, ingestion, matching, and reconciliation path. A deployment selects one endpoint; changing it does not change identity or matching rules. Source IDs and revisions keep observations and caches separate.
|
||||
|
||||
This is not a Lidarr integration. BrainzMash also exposes a Lidarr-shaped API at `https://lidarrapi.brainzmash.cc`, but Allstarr uses only the read-only MusicBrainz-compatible `https://api.brainzmash.cc/ws/2` endpoint. The shared client reads only the artist, release-group, release, recording, search, relationship, image, and identifier data needed for Allstarr's local projection.
|
||||
|
||||
Spotify remains a source of user intent and current source metadata. The selected native backend and external streaming providers remain sources of playable candidates. Allstarr resolves the Spotify row to a canonical recording, independently scores local and provider candidates, persists every accepted route, and selects local first followed by the quality/provider policy. BrainzMash neither decides matches nor selects playback routes.
|
||||
|
||||
Normal requests identify themselves as `Allstarr/{AppVersion.Version} (+https://github.com/SoPat712/allstarr)`, using the application's authoritative version. Public MusicBrainz uses its published rate limit. BrainzMash use requires operator approval for the Allstarr identity. The operator-authorized `DroppedNeedleApp/backend-test` identity is limited to development qualification and must be removed once Allstarr enters the pool.
|
||||
|
||||
All catalog responses are cached in PostgreSQL with source provenance and freshness. Durable jobs refresh them outside request paths. If a source lacks a recording, Allstarr keeps a provisional identity from Spotify or provider evidence and reconciles it later. Search remains available from the last local projection during an outage, and playback never waits for a catalog source.
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
### Stage 0: Freeze the release boundary
|
||||
|
||||
- Define one release composition containing both native protocol adapters, catalog/search, playlists/matching, streaming/routes, accounts/settings, activity, jobs, and recovery. A deployment activates exactly one configured native backend.
|
||||
- Disable deferred routes, navigation, background registrations, and marketing copy together; do not merely hide tabs.
|
||||
- Record production line count and owner count for every stage. Each consolidation stage must remove a duplicate owner and should reduce net production code.
|
||||
|
||||
Exit: a release build cannot start Intelligence schedules or expose its API/UI accidentally, and no primary flow links to a shelved surface.
|
||||
|
||||
Working-tree checkpoint (2026-09-14): Stage 0 is implemented but not deployed. `ReleaseComposition` is the single release-inclusion authority. The default `core` profile excludes the Intelligence and ListenBrainz-intake controllers, recommendation/import/enrichment handlers, AudioMuse capability, recommendation schedules, and direct Intelligence WebUI loading. The explicit `development` profile restores them without deleting persisted data. Playback observations, external scrobbling, playlists, both protocol implementations, and extensions remain composed.
|
||||
|
||||
| Stage 0 measure | Start | Current | Interpretation |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Production source in the established C#/WebUI/Python scope | 135,298 | 135,417 | +119 lines establish and test one enforceable release boundary; this is infrastructure addition, not a claimed consolidation saving |
|
||||
| Release-inclusion decision owners | scattered conditionals | 1 | `ReleaseComposition` owns inclusion; host, scheduler, session contract, and WebUI consume it |
|
||||
|
||||
The source count excludes tests, migrations/generated EF, documentation, and build output. Subsequent consolidation stages must report against the 135,417-line checkpoint and remove duplicate production ownership rather than merely moving code.
|
||||
|
||||
### Stage 1: Make settings and ownership deterministic
|
||||
|
||||
- Implement immutable effective-policy snapshots.
|
||||
- Remove duplicated provider/settings declarations.
|
||||
- Correct cache and downloaded-mapping scope.
|
||||
- Prove private/shared accounts with two users across requests, jobs, playlists, and cached playback.
|
||||
|
||||
Exit: one diagnostic explains the effective provider order, quality, account scope, and reason for every selected route without exposing secrets.
|
||||
|
||||
Working-tree checkpoint (2026-09-14, complete): `EffectiveProviderPolicyResolver` now creates one immutable, tenant-scoped snapshot containing capability order, disabled providers, audio quality, and local-preference policy. Authenticated provider search, playlist discovery, matching, playlist projection, lyrics, activity presentation, protocol playback, managed downloads, click-to-stream diagnostics, and admin schema presentation consume that snapshot instead of the default tenant's mutable process configuration. Matching derives an independent engine from the snapshot, so playlist jobs or rematches for one tenant cannot rewrite another tenant's local-preference window. A client-supplied bandwidth cap may lower playback quality; otherwise the tenant's shared quality policy is passed through the provider contract. The legacy projector may migrate old provider-specific quality ceilings into the shared setting once, but tenant changes no longer mutate singleton Apple, Deezer, or Qobuz options or write provider policy back into global configuration. The admin schema falls back to bootstrap policy only when durable storage is unavailable, preserving the recovery surface. `ProviderOrderPolicyCatalog` is the single owner of provider-order keys and defaults. The administrator-only `GET /api/admin/config/effective-provider-policy` diagnostic reports effective orders, disabled providers, audio quality, account-scope counts, and the local/external selection rules without returning account IDs or secrets. Warmed-file mappings are scoped by tenant, authorized provider account, library, and effective quality; legacy permanent-download mappings remain explicitly separate. Provider priority is evaluated before cache preference. External playback observations retain a safe priority/fallback, account-class, and cache/remote reason; native playback reports `native-local-library`. The remaining `ProviderStatusManager` order readers serve actorless compatibility fallbacks and bootstrap capability readiness only; authenticated protocol routes do not use them as tenant policy. The isolated PostgreSQL matrix proves tenant/account/library/quality cache separation, private/shared account behavior, runtime-setting persistence, migrations, and native transactions. Stage 2 may proceed without another policy owner or credential store.
|
||||
|
||||
| Stage 1 checkpoint measure | Stage start | Current | Interpretation |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Production source in the established C#/WebUI/Python scope | 135,417 | 135,882 | +465 lines introduce the tenant snapshot, migrate its consumers, scope warmed-file references, and expose safe route diagnostics; no net reduction is claimed yet |
|
||||
| Provider-order key/default owners | 3+ | 1 canonical definition | `ProviderOrderPolicyCatalog` replaces repeated operational defaults across routing, matching, projection, discovery, status, and metadata readers |
|
||||
|
||||
### Stage 2: Expand the canonical schema and ingest catalog facts
|
||||
|
||||
- Add artist, release-group, release, release-track, alias, provenance, and freshness records around the existing recording graph.
|
||||
- Build idempotent ingest and refresh jobs with bounded rate/concurrency policies.
|
||||
- Migrate existing source snapshots, provider identities, library tracks, and protocol IDs without discarding manual decisions.
|
||||
- Qualify the shared `/ws/2` adapter against public MusicBrainz and operator-approved BrainzMash access, including missing, stale, duplicate, edition, featured-artist, and explicit/version cases.
|
||||
- Merge Spotify/provider evidence into provisional records when the selected catalog source has no usable entity, then reconcile those records idempotently when canonical data appears.
|
||||
|
||||
Exit: representative Jellyfin, Navidrome/OpenSubsonic, and provider entities converge on one graph; rerunning ingest is safe; the app remains usable from cached data during an upstream outage.
|
||||
|
||||
#### Stage 2 status
|
||||
|
||||
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.
|
||||
- **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.
|
||||
- **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 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| 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 |
|
||||
|
||||
### Stage 3: Replace provider-shaped search with catalog search
|
||||
|
||||
- Query canonical projections and overlay user-authorized route availability.
|
||||
- Return stable Allstarr IDs and resolve legacy aliases.
|
||||
- Implement coherent artist, release, track, and discography browse.
|
||||
- Keep native Jellyfin and Subsonic/OpenSubsonic passthrough behavior and response fixtures unchanged where no Allstarr injection is required.
|
||||
|
||||
Exit: the same recording discovered through either native backend and multiple providers appears once, opens one details view, and remains addressable after a route disappears.
|
||||
|
||||
### Stage 4: Finish playlist-to-canonical matching
|
||||
|
||||
- Resolve source entries to canonical recordings, then discover and persist all accepted routes.
|
||||
- Use catalog facts as evidence while retaining deterministic offline/source-only behavior.
|
||||
- Consolidate the old Spotify import/settings and orphan sync paths.
|
||||
- Complete bulk rematch, manual authority, unresolved visibility, and stable listener projection.
|
||||
|
||||
Exit: one-time and linked imports preserve all rows and order; refresh/rematch changes routes without changing playlist identity; accepted local always wins playback.
|
||||
|
||||
### Stage 5: Harden the playback path
|
||||
|
||||
- Resolve a stable recording ID to accepted routes without remote search.
|
||||
- Enforce local first; for external routes, enforce target-quality eligibility, configured provider order within the tier, then explicit lower-tier fallback when allowed.
|
||||
- Normalize each provider/account representation into shared quality tiers and validate the opened response instead of trusting the requested format alone.
|
||||
- Unify typed streaming/download ownership and delete the legacy fallback path after parity coverage.
|
||||
- Qualify ranges, seeks, cancellation, authorization failures, provider outage fallthrough, and serving-provider reporting.
|
||||
|
||||
Exit: Musiver and the supported Jellyfin and Subsonic/OpenSubsonic clients receive playable bytes inside their agreed startup budgets, and route failures have deterministic bounded behavior.
|
||||
|
||||
### Stage 6: Complete external-to-local acquisition
|
||||
|
||||
- Unify keep, playlist retention, and favorite-triggered acquisition through durable jobs and managed-file ownership.
|
||||
- Verify, tag, place, request the selected backend's scan/rescan, observe the new native item, attach it to the canonical recording, and transition future playback to local.
|
||||
- Present each lifecycle state and recovery action honestly.
|
||||
|
||||
Exit: a kept recording survives external-provider removal and remains in the same playlist/search identity through its transition into Jellyfin or Navidrome/OpenSubsonic.
|
||||
|
||||
### Stage 7: Qualify extensions and marketplace distribution
|
||||
|
||||
- Define the versioned marketplace index, trust/verification metadata, availability behavior, and compatibility rules.
|
||||
- Complete browse/search, permission inspection, install from curated and explicit sources, update discovery, manual update, disable, rollback, and uninstall journeys.
|
||||
- Route extension authentication, settings, secrets, networking, background work, cache, matching, and playback through the same bounded owners as built-ins.
|
||||
- Qualify at least two real provider extensions with different capability combinations so the SDK is proven by use rather than declared stable from abstractions alone.
|
||||
|
||||
Exit: a non-developer can discover, inspect, install, configure, update, recover, and remove a provider extension without shell or database access, and a broken extension cannot prevent native-backend use or core startup.
|
||||
|
||||
### Stage 8: Reduce and refine the WebUI
|
||||
|
||||
- Design around listener/operator tasks rather than internal subsystems.
|
||||
- Use one responsive shell, scrollable tabs, dialog geometry, table primitives, empty/loading/error states, and theme tokens.
|
||||
- Remove controls with no valid target and prevent duplicate navigation/configuration owners.
|
||||
- Keep dense comparison where matching needs it; keep normal listener search and playlists simple.
|
||||
|
||||
Exit: desktop and mobile browser suites cover the full real-API journeys with keyboard, touch dragging where used, reduced motion, light/dark themes, and no overflow.
|
||||
|
||||
### Stage 9: Release qualification
|
||||
|
||||
- Run deterministic unit, PostgreSQL integration, protocol fixture, migration, backup/restore, browser, and provider-failure suites.
|
||||
- Make code scanning a release gate. Re-run CodeQL on the release commit, fix findings in the shared trust-boundary owner, and close alerts whose reported paths no longer exist only by proving the scanned commit contains their removal. Do not dismiss a live finding merely because it is inconvenient.
|
||||
- Start with GitHub's current baseline of 525 open findings on `main` commit `9887fd69` (29 high-severity path-injection, cross-site-scripting, or user-controlled-bypass findings; 494 medium-severity log-forging findings; and two medium workflow-permission findings). That scan predates the current `dev` tree and reports removed controllers plus obsolete line locations, so run CodeQL on the release candidate before claiming any C# finding still exists or is fixed. Resolve surviving high-severity boundary findings first, then replace repeated log sanitization with one tested structured-log boundary rather than controller-specific copies. Set explicit least-privilege workflow permissions immediately because that finding is independent of the code rewrite.
|
||||
- Require zero open critical/high findings for the RC. Every remaining medium/low finding needs either a code fix or a documented, line-specific false-positive decision reviewed against the release commit.
|
||||
- Add regressions for owned-path containment, traversal and symlink handling, proxy route/auth boundaries, output encoding/content types, and CR/LF-neutralized diagnostic fields. Security fixes must preserve native Jellyfin/Subsonic compatibility and managed-file recovery.
|
||||
- Run the live Jellyfin 12 smoke and timing suite with the actual configured providers and at least two user scopes.
|
||||
- Run an equivalent live Subsonic/OpenSubsonic suite against the qualified Navidrome version: authentication modes, ping/capabilities, search/browse, canonical virtual IDs, cover art, playlists, stars, scrobble/now-playing, stream/ranges/transcoding, errors, and at least two user scopes.
|
||||
- Require the same canonical matching, provider fallback, quality, account-isolation, and acquisition-to-native reconciliation fixtures through both protocol surfaces; protocol adapters may translate shapes but cannot implement separate business rules.
|
||||
- For every configured streaming account, test representative tracks at every supported requested tier and record whether playback is full-length, the actual returned format/quality, range behavior, first-byte timing, and downgrade/fallback outcome. Treat credential type, observed account tier, region, and provider response as part of the qualification identity.
|
||||
- Qualify Deezer ARL behavior by probing the connected account rather than assigning a fixed ARL quality. An ARL may yield full-length low-quality playback or a higher tier depending on that account and region. Subscription metadata is only a hint: clamp the stored account-scoped quality envelope to opened, validated media and refresh the observation after authentication or capability changes.
|
||||
- Cover low- and high-quality account envelopes with deterministic provider fixtures even when live CI has only one account tier. A live run reports exactly what its credential proved; it does not define universal provider capability.
|
||||
- Record exact commit, image digest, Compose config, backend/client versions, catalog freshness, provider eligibility, timing percentiles, and failures.
|
||||
- Pilot upgrade and rollback on a copy of real data before the public image is promoted.
|
||||
|
||||
Exit: every primary journey has a reproducible automated regression and a recorded live qualification, and the release commit meets the code-scanning gate. Missing credentials or skipped providers are reported as unqualified, not passed.
|
||||
|
||||
## Code-reduction rules
|
||||
|
||||
Code reduction is a release objective, but deleting safety and observability is not simplification.
|
||||
|
||||
1. Keep one owner each for catalog identity, matching decisions, route selection, playlist state, settings, secrets, files, and durable work.
|
||||
2. Do not introduce compatibility abstractions until there are two active consumers. Delete an old owner after its callers and regressions move to the retained owner.
|
||||
3. Prefer shared typed policies and projections over controller-local switches and provider-specific copies.
|
||||
4. Measure production lines removed and registrations eliminated; test growth is acceptable when it protects a consolidation.
|
||||
5. Split large files at ownership boundaries, not into pass-through classes that preserve the same complexity.
|
||||
6. Remove redundant comments that narrate syntax. Keep comments that explain protocol constraints, external quirks, invariants, or non-obvious safety decisions.
|
||||
7. Profile search, matching, and first-byte latency before adding caches or concurrency. An optimization needs a measured bottleneck and an invalidation/ownership model.
|
||||
|
||||
## Explicit non-goals for the first public release
|
||||
|
||||
- Personalized recommendations, generated mixes, listening-history imports, and the Intelligence workbench.
|
||||
- A mandatory Lidarr installation or Lidarr as the canonical database.
|
||||
- Provider-branded duplicate artist/discography experiences.
|
||||
- Protocol/provider support claims without equivalent qualification evidence.
|
||||
- Fuzzy discovery, catalog refresh, transcoding changes, or provider splicing after Play has begun.
|
||||
- Unattended extension updates, ratings/reviews, arbitrary UI injection, or speculative capability types without a qualified provider need.
|
||||
- Advanced selective state transfer, in-app Docker control, or environment-file editing as ordinary user settings.
|
||||
|
||||
## Plan closure and change control
|
||||
|
||||
This plan is complete enough to execute without another architecture decision. Remaining unknowns are qualification evidence or external access dependencies, not invitations to create parallel catalog, matching, routing, account, cache, playlist, or job systems.
|
||||
|
||||
The following decisions are locked for the release candidate:
|
||||
|
||||
1. One canonical Allstarr entity owns many independently accepted routes. Provider identities never become the song, artist, or release identity.
|
||||
2. Local Jellyfin or Subsonic/OpenSubsonic playback wins whenever it is an accepted match. External quality eligibility is evaluated next, then the effective provider order; confidence is never used as a provider preference bonus.
|
||||
3. Public MusicBrainz and BrainzMash are equivalent `/ws/2` sources behind one catalog client. A deployment selects either endpoint without changing identity or matching semantics. Caches and provenance remain source-scoped. The separate Lidarr/Aurral API is not implemented. Metadata is projected into PostgreSQL and never enters the Play critical path.
|
||||
4. Playlist source rows, order, unresolved visibility, and manual authority survive refreshes and algorithm revisions. One-time and linked imports remain distinct.
|
||||
5. Keep is not successful until the selected native backend indexes the file and Allstarr reconciles its local route.
|
||||
6. Accounts, caches, jobs, routes, and artifacts use the same tenant/user/library/account boundaries. Sharing is explicit and audited.
|
||||
7. Extensions and a curated marketplace ship through the existing capability and permission boundaries; extension-owned schedulers and arbitrary UI/process access do not.
|
||||
8. Intelligence and recommendations remain outside the core release composition while now-playing observation and independently configured scrobbling remain available.
|
||||
9. Refactoring must retire duplicate owners and report net production-line and registration changes. Test or migration growth is not counted as application bloat, and safety is not deleted to improve a line count.
|
||||
10. The release candidate must pass the deterministic, PostgreSQL, browser, protocol, live-provider, performance, recovery, and fresh CodeQL gates in Stage 9 before deployment to users.
|
||||
|
||||
Normal deployments identify requests as `Allstarr/{AppVersion.Version} (+https://github.com/SoPat712/allstarr)` and may use public MusicBrainz immediately under its published limits. BrainzMash requires operator approval for that identity. The temporary `DroppedNeedleApp/backend-test` override is limited to the operator-authorized development qualification and must be removed once Allstarr is approved. The live qualifier records endpoint, source revision, status, latency, byte count, and response shape without storing query text or payloads.
|
||||
|
||||
Any proposed change to a locked decision must update this document, the owning architecture/protocol document, and the affected acceptance fixtures in one review. Passing a narrower happy path does not amend the release contract.
|
||||
|
||||
This plan supersedes the earlier decision to exclude a unified catalog from the eventual first public release. The narrower [Jellyfin/provider routing plan](jellyfin-provider-routing-plan.md) remains the Jellyfin component-level route contract and an intermediate qualification milestone; the same core rules require an equivalent Subsonic/OpenSubsonic protocol qualification before the public release claims both backends.
|
||||
@@ -46,7 +46,7 @@ Injected song titles use `[A]`, or `[A]/[E]` when explicit, including playlist s
|
||||
|
||||
For clients with a device identifier, playback first checks authorized cached copies, then tries configured streaming providers with an exact verified identity for the same recording. Manual source pins remain authoritative. Failed leases, transport failures, unavailable media, empty streams, and non-audio error pages can advance to another eligible source before audio starts. Authentication and policy denials do not bypass authorization, and an active response is never spliced together from multiple providers. A track without verified alternatives stays on its known source; playback does not run a speculative title search.
|
||||
|
||||
The administrator Home page distinguishes **Playing from**, **Cached from**, and an unconfirmed catalog source. Jellyfin song details can include the last opened source for that user/device, with its timestamp; whether a client displays `Overview` or the media-source name depends on the client. The client must request song details again after the stream opens to receive this observation; metadata fetched before playback cannot name a confirmed source. Musiver's in-player display of this information is not yet qualified. Stream responses also expose `X-Allstarr-Provider`, never account identifiers or signed URLs. Artwork is deliberately not stamped: cached/shared covers cannot reliably represent a per-listener playback route.
|
||||
The administrator Home page distinguishes **Playing from**, **Cached from**, and an unconfirmed catalog source. Hovering the route detail exposes the safe route-reason code recorded for that stream. Native backend playback uses `native-local-library`; external playback reports provider priority or fallback, account class, and cache/remote delivery without exposing account identifiers. Jellyfin song details can include the last opened source for that user/device, with its timestamp; whether a client displays `Overview` or the media-source name depends on the client. The client must request song details again after the stream opens to receive this observation; metadata fetched before playback cannot name a confirmed source. Musiver's in-player display of this information is not yet qualified. Stream responses expose `X-Allstarr-Provider` and `X-Allstarr-Route-Reason`, never account identifiers or signed URLs. Artwork is deliberately not stamped: cached/shared covers cannot reliably represent a per-listener playback route.
|
||||
|
||||
Byte-range continuation stays on the opened provider, identity, account, and requested quality. If the short-lived selection is unavailable (for example after a restart), the client must restart playback before seeking. Clients without a device identifier—including standard Subsonic requests that identify only the application name—retain exact-provider playback and seeking; they do not participate in cross-provider failover yet. HEAD probes do not change the reported playback source.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Allstarr separates deployment bootstrap, durable application settings, and encry
|
||||
|
||||
| Group | Current values |
|
||||
| --- | --- |
|
||||
| Backend selection | `BACKEND_TYPE` |
|
||||
| Backend and release selection | `BACKEND_TYPE`, `ALLSTARR_RELEASE_PROFILE` |
|
||||
| PostgreSQL bootstrap | `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD_FILE` |
|
||||
| Encryption bootstrap | `ALLSTARR_KEYRING_FILE` |
|
||||
| Image and media mounts | `ALLSTARR_IMAGE`, `DOWNLOAD_PATH`, `KEPT_PATH`, `APPLE_UPLOAD_PATH` |
|
||||
@@ -17,6 +17,7 @@ Allstarr separates deployment bootstrap, durable application settings, and encry
|
||||
| Extension install policy | `EXTENSIONS_ALLOW_REMOTE_INSTALL` |
|
||||
| Browser origin policy | `CORS_ALLOWED_ORIGINS`, `CORS_ALLOW_CREDENTIALS` |
|
||||
| Optional Spotify lyrics bootstrap | `SPOTIFY_API_SESSION_COOKIE` |
|
||||
| Canonical metadata upstream | `MUSICBRAINZ_SOURCE_ID`, `MUSICBRAINZ_BASE_URL`, `MUSICBRAINZ_RATE_LIMIT_MS`, exceptional `MUSICBRAINZ_AUTHORIZED_USER_AGENT_OVERRIDE` |
|
||||
|
||||
The Compose file translates these values into ASP.NET configuration. Changing one requires recreating the affected container. Allstarr does not hot-edit its own Compose deployment.
|
||||
|
||||
@@ -36,6 +37,8 @@ Non-secret product behavior belongs in tenant-scoped PostgreSQL settings and is
|
||||
|
||||
`DurableRuntimeSettingsService` owns validation, typing, revisions, and optimistic concurrency. Controllers must not add a second environment or JSON owner for these settings.
|
||||
|
||||
Administrators can inspect the resolved, secret-free policy at `GET /api/admin/config/effective-provider-policy`. The response contains the tenant's capability orders, disabled providers, audio-quality target, local-preference window, account-scope counts, and selection rules. It deliberately omits account IDs, credentials, tokens, and provider payloads.
|
||||
|
||||
## Provider accounts
|
||||
|
||||
Provider credentials are encrypted and persisted as provider accounts with explicit tenant, user/shared scope, capability, and access policy. Services and their configuration are managed under **Integrations > Services**. Credentials and audience policy live under **Integrations > Accounts**; capability priority lives under **Integrations > Routing**.
|
||||
@@ -46,7 +49,7 @@ Provider credentials are encrypted and persisted as provider accounts with expli
|
||||
|
||||
Extensions are package implementations, not a second account system. Their install, update, permission, rollback, and removal lifecycle lives under **Integrations > Extensions**. Once active, their Services and Accounts use the same Integrations surfaces as built-in providers.
|
||||
|
||||
AudioMuse-AI is a built-in Intelligence integration rather than an extension. Its self-hosted URL, optional token, and optional music-server selector live under **Intelligence > Automation**; shared health remains visible in Integrations.
|
||||
AudioMuse-AI is a built-in Intelligence integration rather than an extension. It is composed only when `ALLSTARR_RELEASE_PROFILE=development`; its persisted configuration and data remain intact while the core profile is active.
|
||||
|
||||
A shared account is not automatically available to every user. Administrators must set its access policy explicitly.
|
||||
|
||||
@@ -54,6 +57,22 @@ A shared account is not automatically available to every user. Administrators mu
|
||||
|
||||
`BACKEND_TYPE` selects Jellyfin or Subsonic/OpenSubsonic before startup. Backend URL, credentials, instance identity, library selection, and user mapping are completed through onboarding and durable configuration. An imported legacy file must not switch the active backend.
|
||||
|
||||
`ALLSTARR_RELEASE_PROFILE` defaults to `core`. The core profile omits unreleased Intelligence controllers, recommendation providers, imports, background handlers, and direct WebUI access while preserving their database records. `development` explicitly composes those surfaces for continued work. Unknown profile names stop startup instead of silently widening the release.
|
||||
|
||||
## Canonical metadata upstream
|
||||
|
||||
Allstarr's catalog client speaks the read-only MusicBrainz `/ws/2` contract. The public MusicBrainz service remains the bootstrap default. After the BrainzMash operator approves Allstarr's user agent, select the pool without changing matching or catalog ownership:
|
||||
|
||||
```dotenv
|
||||
MUSICBRAINZ_SOURCE_ID=brainzmash
|
||||
MUSICBRAINZ_BASE_URL=https://api.brainzmash.cc/ws/2
|
||||
MUSICBRAINZ_RATE_LIMIT_MS=1000
|
||||
```
|
||||
|
||||
This is the MusicBrainz-compatible front door used by DroppedNeedle. Do not append `/ws/2` to `https://lidarrapi.brainzmash.cc`: that is the separate Lidarr/Aurral-shaped API with `/artist`, `/album`, and `/search/*` resources, and Allstarr deliberately does not implement that second dialect. Requests identify themselves with the version derived from `AppVersion.Version` and the Allstarr repository URL. Source IDs isolate cache entries and provenance, so changing the upstream cannot reuse another source's response. A 401 or 403 is an access/configuration failure, not a missing recording; do not retry it as catalog churn. Playback uses persisted routes and remains available when the metadata upstream is unavailable.
|
||||
|
||||
`MUSICBRAINZ_AUTHORIZED_USER_AGENT_OVERRIDE` is an exceptional, temporary operator-approved compatibility setting. Leave it unset for normal operation so Allstarr derives its identity from `AppVersion.Version`. Never copy another application's identity without explicit permission from the upstream operator; remove the override as soon as Allstarr's own identity is admitted.
|
||||
|
||||
## Optional services
|
||||
|
||||
Use `allstarr.sh` rather than editing Compose fragments:
|
||||
|
||||
@@ -117,10 +117,12 @@ release is available.
|
||||
## Apple Music cold playback
|
||||
|
||||
For an uncached `apple-download` track, Allstarr opens the compatible sidecar's
|
||||
`api/stream/{id}` response with `ResponseHeadersRead`. The sidecar
|
||||
streams FFmpeg's FLAC stdout after Apple fetch/decryption instead of waiting
|
||||
for a second complete converted file. The gateway returns FLAC, so Allstarr
|
||||
relays those exact bytes immediately while teeing the
|
||||
`api/stream/{id}` response with `ResponseHeadersRead`. The sidecar first emits a
|
||||
metadata-free ID3v2.4 padding tag accepted by the supported FLAC readers, then
|
||||
streams FFmpeg's FLAC stdout after Apple fetch/decryption. This opens the response
|
||||
for clients with short prefix deadlines without inserting audio, changing
|
||||
duration, resampling, or routing to another provider. The gateway returns FLAC,
|
||||
so Allstarr relays those exact bytes immediately while teeing the
|
||||
same bytes to a temporary file. Metadata resolution happens concurrently and is
|
||||
used only when the completed cache file is published. A partial artifact is never
|
||||
registered. Completed cache files support normal byte ranges and seeks.
|
||||
|
||||
@@ -4,6 +4,8 @@ Assessment date: 2026-09-12. Reference commit: `adf3c585a3b97b025dfa65f99a33b470
|
||||
|
||||
This is a dated engineering assessment and proposed plan, not a statement that every described feature is released or qualified. It inventories the working tree, examines Git churn, and traces the main listening, acquisition, playlist, account, and recovery paths. It is a repository-wide inventory with focused code review of those paths, not a line-by-line review of every file. The 2026-09-13 addendum records the maintainer's Intelligence scope decision, the multi-source routing proposal, a WebUI audit, and a small navigation implementation. No deployment was performed for either assessment.
|
||||
|
||||
Planning note (2026-09-14): the later [unified music service plan](architecture/unified-music-service-plan.md) supersedes this assessment's narrow “no unified catalog in the first public release” scope decision. This document remains the dated implementation and code-churn evidence; the newer plan owns future product scope and sequencing.
|
||||
|
||||
## Product contract
|
||||
|
||||
**Allstarr should help people listen to and grow their own music library.** It preserves their existing Jellyfin or Subsonic client, supplies missing recordings through secondary providers, and lets them explicitly acquire music into storage their media server can index.
|
||||
@@ -59,9 +61,9 @@ Provider names can leave the main song title, but must remain available in detai
|
||||
|
||||
Lidarr is a possible later acquisition and file-organization adapter, not the recording authority or a replacement matching database. Its job is monitoring releases and coordinating indexers, download clients, naming, and upgrades. Allstarr should continue to own the scoped recording-to-provider/local identity graph, using MusicBrainz recording IDs, ISRCs, duration/version evidence, and fingerprints when available. Keep recording identity separate from release-track identity: one recording may appear on several releases, while live, remix, instrumental, clean, and explicit audio must remain distinct. Integrate Lidarr only after the direct keep/download-to-index workflow is qualified, behind the existing durable job and managed-file boundaries.
|
||||
|
||||
**First-release sequence:** (1) specify source-set/manual-authority semantics and regressions; (2) finish scoped pre-commit failover in the existing gateway; (3) complete acquisition-to-native reconciliation; (4) migrate external identities and presentation only when aliases and client compatibility are proved. If identity migration cannot be qualified, retain current IDs for the first release rather than announcing provider-neutral catalog identity prematurely.
|
||||
**First-release sequence:** (1) specify source-set/manual-authority semantics and regressions; (2) finish scoped pre-commit failover in the existing gateway; (3) complete acquisition-to-native reconciliation; (4) qualify the accepted [Jellyfin and provider routing plan](architecture/jellyfin-provider-routing-plan.md) on the maintainer's deployment. Existing IDs remain aliases throughout migration.
|
||||
|
||||
**Later, separately scoped:** merge artist catalogs and discographies. This needs artist disambiguation, recording versus release-track identity, edition/version preservation, source attribution, deterministic deduplication/pagination, and partial-provider failure behavior. Do not merge live/remix/clean/explicit versions merely because names resemble each other. Full cross-provider artist/album aggregation is not a prerequisite for reliable local-first playback.
|
||||
**Maintainer scope decision:** the RC target is one proven Jellyfin deployment plus its connected providers, backed by Allstarr's local canonical projection. Public MusicBrainz and BrainzMash are equivalent `/ws/2` catalog sources behind the same bounded client. BrainzMash requires operator approval for Allstarr's user agent; public MusicBrainz uses its published limits. Neither source is a playback dependency, and an outage uses cached or provisional metadata. Self-hosting BrainzMash, SkyHook/Lidarr Metadata, and a local MusicBrainz mirror remain deferred. Recording identity is provider-neutral: each candidate must pass match acceptance independently, an accepted local Jellyfin route wins, and accepted external routes follow configured streaming order. Provider adapters choose the closest allowed representation without reordering providers. Live, remix, clean, and explicit versions remain distinct. See the [audited reference ledger](architecture/reference-projects.md) for reviewed boundaries.
|
||||
|
||||
## Current feature inventory
|
||||
|
||||
@@ -169,15 +171,15 @@ Reproduce the scope with `git status --short`, `git diff HEAD --numstat`, `git l
|
||||
|
||||
**Action:** inventory the outstanding edits into coherent changes, retain or explicitly defer each, and validate a clean candidate. Record its commit, application image digest, optional sidecar versions, migrations, and support matrix. Do not use a checkout SHA as evidence that an older application container contains the same code. Source updates also require a clean tree in the documented updater.
|
||||
|
||||
### R2 — External cache hits bypass the account-aware provider route
|
||||
### R2 — External cache ownership and authorization
|
||||
|
||||
**Confirmed control-flow and schema gap; no cross-user live exploit test was performed.** [`DownloadedSongMappingEntity`](../allstarr/Core/Downloads/DownloadedSongMappingPersistence.cs) has a unique `(ProviderId, ExternalId)` key without tenant, owner, library, or account. [`LocalLibraryService.GetLocalPathForExternalSongAsync`](../allstarr/Services/Local/LocalLibraryService.cs) resolves that key without a caller. Both [`JellyfinController.Audio`](../allstarr/Controllers/JellyfinController.Audio.cs) and [`SubsonicController`](../allstarr/Controllers/SubsonicController.cs) return the cached file before entering `OpenStreamAsync`. Native authentication still runs; the missing boundary is authorization to reuse that external artifact.
|
||||
**Working-tree fix implemented; live two-client qualification remains.** External cache lookup follows candidate authorization in the shared protocol gateway. `DownloadedSongMappingEntity` now separates warmed files by an opaque scope derived from tenant, resolved provider account, library, and effective audio quality. Fallback audio is published under its actual provider/track and scoped mapping; existing provider-only permanent-download mappings remain in the explicit legacy scope rather than being silently reclassified.
|
||||
|
||||
**Impact:** personal account policy and revocation cannot be assumed to cover warmed files. A shared physical cache can be valid, but it needs an explicit authorized-sharing policy and scoped references.
|
||||
The PostgreSQL regression matrix exercises exact-scope visibility across tenant, provider account, library, and quality plus explicit legacy separation. It must pass against an isolated database before deployment; the live application database is not a test target.
|
||||
|
||||
**Action:** authorize artifact access in the shared playback owner before opening a cached file. Test user A/user B, revoked access, different libraries, same external ID, and explicit shared-library policy on both protocols. Preserve legitimate playback of music already acquired into an authorized local library when the external account later disappears.
|
||||
**Result:** a private account's warmed reference cannot satisfy another account, tenant, library, or quality lookup. Users intentionally sharing one authorized account within the same tenant and library can reuse its warmed file. Account revocation prevents the gateway from reaching the scoped cache lookup at all.
|
||||
|
||||
**Working-tree update:** external cache lookup now follows candidate authorization in the shared gateway, and fallback audio is published under its actual provider/track. Actor-scoped misses no longer fall through to legacy global playback. This fixes the two controller shortcuts described above; it does not replace the provider-keyed artifact schema or complete the scoped artifact-ownership and native-acquisition lifecycle qualification.
|
||||
**Remaining qualification:** run user A/user B, revoked access, different libraries, same external ID, and explicit shared-account scenarios through both Jellyfin and Subsonic clients against an isolated migrated database. Preserve legitimate playback of music already acquired into an authorized native library when the external account later disappears; that is the retained-file lifecycle in R3, not a cache exception.
|
||||
|
||||
### R3 — “Kept” has conflicting roots, records, and mutation paths
|
||||
|
||||
@@ -377,6 +379,14 @@ Remaining account work, in release order:
|
||||
4. Add scoped listener connection probes and clear readiness, usage limits, and provider concurrency guidance. Do not enable an operator-wide diagnostics endpoint as a shortcut.
|
||||
5. Keep per-user provider preference, per-capability sharing, and share expiry as follow-up refinements after those safety and daily-use gates, not prerequisites for another routing rewrite.
|
||||
|
||||
### Deterministic policy and cache-scope checkpoint
|
||||
|
||||
Stage 1 of the unified-service plan is implemented in the working tree, not deployed. Authenticated routing, matching, playlists, lyrics, activity, downloads, playback, and admin presentation now resolve the same immutable tenant policy. Provider order, disabled capabilities, audio quality, local preference, and authorized account scope no longer depend on mutable singleton provider settings. Warmed media references include tenant, provider account, library, and effective quality; permanent managed downloads remain a separate legacy-compatible path.
|
||||
|
||||
Verification on 2026-09-14 used a disposable PostgreSQL 18.4 instance and did not connect to or mutate the live database. Two focused runs passed **39 tests with zero failures**: exact cache isolation across tenant/account/library/quality, identity and provider-account sharing behavior, durable runtime settings, migration consistency, and PostgreSQL identity/job/outbox transactions. The disposable database and its tunnel were removed after the run. Release build, focused unit/controller suites, WebUI type checking, formatting verification, and diff whitespace checks also passed. A broad run without `ALLSTARR_TEST_POSTGRES` reported PostgreSQL fixtures as dynamic skips; that run is not counted as database qualification.
|
||||
|
||||
Stage 2 is partly implemented in the working tree and is not deployed. The existing recording graph now includes canonical artists, ordered credits, release groups, editions, release tracks, legacy/protocol aliases, and source-stamped facts. One evidence writer makes repeated payloads idempotent, preserves superseded facts, and rejects alias remapping and hash collisions. Atomic graph ingestion, bounded discovery, and durable refresh are implemented and covered by isolated PostgreSQL tests. Public MusicBrainz and BrainzMash share one source-neutral `/ws/2` client with source-scoped caches and provenance. Catalog-backed search, legacy projection, relationship/image reads, and provisional reconciliation remain release work and must not be advertised as complete.
|
||||
|
||||
### Provider-neutral playback implementation and qualification
|
||||
|
||||
Implemented in the working tree, not deployed. External titles now use `[A]` for Allstarr injection and `[A]/[E]` for explicit tracks; native titles and existing item IDs remain unchanged. The existing routing owner chooses authorized cached audio first, then configured streaming providers with verified alternatives. It honors manual pins, excludes tentative/released/replaced alternatives, and can advance on lease, HTTP, transport, empty-body, or incompatible-media failures before committing audio. A single bounded retry follows the lease policy; the protocol deadline and cancellation remain effective. Cache publication uses the serving identity, not the original catalog ID.
|
||||
|
||||
+3
-1
@@ -112,7 +112,9 @@ The default **Mapped** view always preserves the source playlist's full order. L
|
||||
|
||||
An accepted match is reusable across playlist sync, search, playback, and later rematches. A matched local item is returned as the complete native backend object. A genuinely external item keeps a stable virtual identity and provider label.
|
||||
|
||||
Automatic matching keeps confidence separate from routing preference. A local Jellyfin or Subsonic result wins when it is within 7 confidence points of the strongest candidate. If local is outside that window, the configured playback providers are considered in order with windows of 5, 3, and 1 point; every later provider must have the highest confidence outright. Exact boundaries favor the earlier route. The acceptance threshold still uses the selected candidate's raw confidence.
|
||||
Automatic matching keeps confidence separate from routing preference. Among candidates that pass the acceptance threshold and artist-evidence checks, the local Jellyfin or Subsonic match wins; external matches follow **Integrations → Routing → Streaming priority**, even when a later provider scores higher. Confidence selects valid matches, not the preferred playback provider. Saved external mappings are rescored against current metadata and local candidates, never automatically promoted to 100%. Matching uses all source artist credits consistently; an exact lead artist, title, album, and close duration can support a threshold-qualified match when secondary credits differ.
|
||||
|
||||
When no candidate qualifies for acceptance, tentative selection uses the local window (7 confidence points by default), then configured provider windows of 5, 3, and 1 point. These windows never increase confidence or turn a tentative result into an accepted match. Interactive search displays raw score rank separately from routing priority; manual choices remain authoritative.
|
||||
|
||||
Use **Library → Mappings → Rematch all** when library metadata or matching behavior has changed. The preview includes resolved, review, and unresolved tracks; active manual pins, rejections, and manually selected provider routes are protected. Allstarr replaces current automatic decisions in durable batches of 25 and yields between batches. Earlier decisions remain available as audit history. A new matching-algorithm version schedules the same gradual replacement automatically for stale decisions.
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ both on Allstarr's private network. Do not expose the gateway or wrapper login e
|
||||
- `GET /api/search?q=...&type=song|album|artist&limit=...`
|
||||
- `GET /api/song/{songId}`
|
||||
- `GET /api/download/{songId}?quality=...` — complete managed FLAC artifact
|
||||
- `GET /api/stream/{songId}?quality=...` — progressive FLAC after Apple fetch/decryption
|
||||
- `GET /api/stream/{songId}?quality=...` — progressive FLAC with an immediate,
|
||||
metadata-free ID3v2 padding prelude while Apple fetch/decryption completes
|
||||
- `HEAD /api/stream/{songId}?quality=...` — FLAC type/disposition only; no media preparation
|
||||
- `POST /api/jobs/download` for supported Apple catalog or library URLs
|
||||
- `GET /api/jobs/download/{jobId}`
|
||||
|
||||
@@ -39,6 +39,17 @@ CAPABILITIES = (
|
||||
)
|
||||
PREPARED_CACHE_TTL_SECONDS = 6 * 60 * 60
|
||||
PREPARED_CACHE_MAX_TRACKS = 32
|
||||
FLAC_GUIDANCE_PADDING_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def _id3_padding_tag(payload_size: int) -> bytes:
|
||||
if not 0 <= payload_size <= 0x0FFFFFFF:
|
||||
raise ValueError("ID3 payload size is outside the synchsafe range")
|
||||
size = bytes((payload_size >> shift) & 0x7F for shift in (21, 14, 7, 0))
|
||||
return b"ID3\x04\x00\x00" + size + bytes(payload_size)
|
||||
|
||||
|
||||
FLAC_GUIDANCE_PREFIX = _id3_padding_tag(FLAC_GUIDANCE_PADDING_BYTES)
|
||||
|
||||
|
||||
def _version(distribution: str) -> str:
|
||||
@@ -334,6 +345,9 @@ def create_app(
|
||||
@application.get("/api/stream/{song_id}")
|
||||
async def stream_song(song_id: str, quality: str = "alac-16-44") -> StreamingResponse:
|
||||
async def content() -> AsyncIterator[bytes]:
|
||||
# ID3v2 tags may precede FLAC. A padding-only tag opens the response
|
||||
# immediately while Apple prepares the real, otherwise unchanged audio.
|
||||
yield FLAC_GUIDANCE_PREFIX
|
||||
source = await prepare_song(song_id, quality, "aac-web")
|
||||
async for chunk in process_runner.stream_flac(source):
|
||||
yield chunk
|
||||
|
||||
@@ -12,7 +12,12 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from apple_gateway.app import API_VERSION, create_app
|
||||
from apple_gateway.app import (
|
||||
API_VERSION,
|
||||
FLAC_GUIDANCE_PADDING_BYTES,
|
||||
FLAC_GUIDANCE_PREFIX,
|
||||
create_app,
|
||||
)
|
||||
from apple_gateway.catalog import CatalogClient
|
||||
from apple_gateway.config import Settings
|
||||
from apple_gateway.runner import BoundedProcessRunner, ProcessFailure
|
||||
@@ -237,7 +242,7 @@ def test_song_download_uses_safe_id_quality_mapping_and_flac_contract(client):
|
||||
streamed = client[0].get("/api/stream/102", params={"quality": "aac-320"})
|
||||
assert streamed.status_code == 200
|
||||
assert streamed.headers["content-type"].startswith("audio/flac")
|
||||
assert streamed.content == b"fLaCfixture"
|
||||
assert streamed.content == FLAC_GUIDANCE_PREFIX + b"fLaCfixture"
|
||||
assert client[2].transcodes == ["file", "stream"]
|
||||
assert client[2].calls[-1] == ("https://music.apple.com/us/album/fixture/1?i=102", "aac")
|
||||
assert client[0].get("/api/download/not-an-id").status_code == 400
|
||||
@@ -260,7 +265,11 @@ def test_song_stream_head_reports_only_known_facts_without_preparing_media(clien
|
||||
async def test_song_stream_opens_before_preparing_configured_quality(settings: Settings):
|
||||
runner = FakeRunner()
|
||||
app = create_app(settings, FakeWrapper(), FakeCatalog(), runner)
|
||||
route = next(route for route in app.routes if getattr(route, "path", None) == "/api/stream/{song_id}")
|
||||
route = next(
|
||||
route
|
||||
for route in app.routes
|
||||
if getattr(route, "path", None) == "/api/stream/{song_id}"
|
||||
)
|
||||
|
||||
response = await route.endpoint("102", "alac-16-44")
|
||||
|
||||
@@ -268,10 +277,58 @@ async def test_song_stream_opens_before_preparing_configured_quality(settings: S
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["x-accel-buffering"] == "no"
|
||||
assert runner.calls == []
|
||||
assert b"".join([chunk async for chunk in response.body_iterator]) == b"fLaCfixture"
|
||||
assert b"".join(
|
||||
[chunk async for chunk in response.body_iterator]
|
||||
) == FLAC_GUIDANCE_PREFIX + b"fLaCfixture"
|
||||
assert runner.calls[-1][1] == "alac"
|
||||
|
||||
|
||||
def test_flac_guidance_prefix_is_a_metadata_free_id3v24_padding_tag():
|
||||
assert FLAC_GUIDANCE_PREFIX[:6] == b"ID3\x04\x00\x00"
|
||||
encoded_size = FLAC_GUIDANCE_PREFIX[6:10]
|
||||
assert all(byte < 0x80 for byte in encoded_size)
|
||||
decoded_size = sum(
|
||||
byte << shift for byte, shift in zip(encoded_size, (21, 14, 7, 0))
|
||||
)
|
||||
assert decoded_size == FLAC_GUIDANCE_PADDING_BYTES
|
||||
assert len(FLAC_GUIDANCE_PREFIX) == 10 + FLAC_GUIDANCE_PADDING_BYTES
|
||||
assert not any(FLAC_GUIDANCE_PREFIX[10:])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_song_stream_sends_guidance_prefix_before_apple_preparation(settings: Settings):
|
||||
class BlockingRunner(FakeRunner):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.started = asyncio.Event()
|
||||
self.release = asyncio.Event()
|
||||
|
||||
async def download(
|
||||
self, url: str, quality: str, output: Path, temporary: Path
|
||||
) -> list[Path]:
|
||||
self.started.set()
|
||||
await self.release.wait()
|
||||
return await super().download(url, quality, output, temporary)
|
||||
|
||||
runner = BlockingRunner()
|
||||
app = create_app(settings, FakeWrapper(), FakeCatalog(), runner)
|
||||
route = next(
|
||||
route
|
||||
for route in app.routes
|
||||
if getattr(route, "path", None) == "/api/stream/{song_id}"
|
||||
)
|
||||
response = await route.endpoint("102", "aac-320")
|
||||
|
||||
assert await anext(response.body_iterator) == FLAC_GUIDANCE_PREFIX
|
||||
assert not runner.started.is_set()
|
||||
|
||||
first_audio = asyncio.create_task(anext(response.body_iterator))
|
||||
await asyncio.wait_for(runner.started.wait(), timeout=0.5)
|
||||
assert not first_audio.done()
|
||||
runner.release.set()
|
||||
assert await asyncio.wait_for(first_audio, timeout=0.5) == b"fLaC"
|
||||
|
||||
|
||||
def test_song_stream_falls_back_to_web_aac_when_lossless_is_unavailable(settings):
|
||||
class FallbackRunner(FakeRunner):
|
||||
async def download(self, url: str, quality: str, output: Path, temporary: Path) -> list[Path]:
|
||||
@@ -319,10 +376,9 @@ async def test_simultaneous_song_streams_share_preparation(settings):
|
||||
route.endpoint("102", "aac-320"),
|
||||
route.endpoint("102", "aac-320"),
|
||||
)
|
||||
await asyncio.gather(*(
|
||||
anext(response.body_iterator)
|
||||
for response in responses
|
||||
))
|
||||
prefixes = await asyncio.gather(*(anext(response.body_iterator) for response in responses))
|
||||
assert prefixes == [FLAC_GUIDANCE_PREFIX, FLAC_GUIDANCE_PREFIX]
|
||||
await asyncio.gather(*(anext(response.body_iterator) for response in responses))
|
||||
|
||||
assert len(runner.calls) == 1
|
||||
|
||||
|
||||
+26
-1
@@ -1,4 +1,28 @@
|
||||
# Jellyfin compatibility qualification
|
||||
# Live qualification tools
|
||||
|
||||
## BrainzMash catalog qualification
|
||||
|
||||
`live_brainzmash_qualification.py` exercises only Allstarr's selected read-only
|
||||
MusicBrainz-compatible contract. It checks representative base/edition,
|
||||
featured-credit, and versioned-title searches; follows one result through its
|
||||
recording, artist, release, release-group, medium, and track hierarchy; verifies
|
||||
404 behavior; enforces HTTPS, the approved host/path, a one-megabyte response
|
||||
limit, no redirects, bounded timeouts, and a conservative request interval.
|
||||
Output contains case names, status, latency, byte counts, and entity counts—not
|
||||
query text or payloads.
|
||||
|
||||
Supply only an identity explicitly authorized by the BrainzMash operator:
|
||||
|
||||
```bash
|
||||
BRAINZMASH_USER_AGENT='DroppedNeedleApp/backend-test' \
|
||||
python3 tools/tests/live_brainzmash_qualification.py
|
||||
```
|
||||
|
||||
The temporary identity is not a deployment default. Once BrainzMash admits
|
||||
Allstarr, rerun with `Allstarr/<current version> (+https://github.com/SoPat712/allstarr)`
|
||||
and remove `MUSICBRAINZ_AUTHORIZED_USER_AGENT_OVERRIDE` from the deployment.
|
||||
|
||||
## Jellyfin compatibility qualification
|
||||
|
||||
The reusable Jellyfin kit has deterministic and live layers:
|
||||
|
||||
@@ -12,6 +36,7 @@ The reusable Jellyfin kit has deterministic and live layers:
|
||||
12.0, including legacy audio HLS and query-form artist instant-mix routes.
|
||||
- `live_jellyfin_smoke.sh` compares a real Jellyfin instance directly with
|
||||
Allstarr. It covers bootstrap and authentication, exact native profile,
|
||||
public server identity apart from the required proxy `LocalAddress`,
|
||||
browse, detail, search, filter, playlist, artwork, and lyrics parity; stable
|
||||
remote-session parity; non-empty virtual playlist projections with client-indexable
|
||||
track/artist/album fields, exact full-object parity between every matched
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded, read-only qualification for Allstarr's BrainzMash /ws/2 contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
MAX_RESPONSE_BYTES = 1024 * 1024
|
||||
DEFAULT_BASE_URL = "https://api.brainzmash.cc/ws/2"
|
||||
|
||||
|
||||
class QualificationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class NoRedirects(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Observation:
|
||||
case: str
|
||||
status: int
|
||||
elapsed_ms: int
|
||||
bytes_read: int
|
||||
entity_count: int | None = None
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base_url: str, user_agent: str, timeout: float, interval_ms: int):
|
||||
parsed = urllib.parse.urlsplit(base_url.rstrip("/"))
|
||||
if parsed.scheme != "https" or parsed.hostname != "api.brainzmash.cc" or parsed.path != "/ws/2":
|
||||
raise QualificationError("The live qualifier only permits https://api.brainzmash.cc/ws/2.")
|
||||
if not user_agent.strip() or "\r" in user_agent or "\n" in user_agent:
|
||||
raise QualificationError("A valid, operator-authorized User-Agent is required.")
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.user_agent = user_agent.strip()
|
||||
self.timeout = timeout
|
||||
self.interval = interval_ms / 1000
|
||||
self.last_request = 0.0
|
||||
self.opener = urllib.request.build_opener(NoRedirects())
|
||||
self.observations: list[Observation] = []
|
||||
|
||||
def get(self, case: str, resource: str, query: dict[str, str] | None = None, expected=(200,)) -> dict:
|
||||
remaining = self.interval - (time.monotonic() - self.last_request)
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
url = f"{self.base_url}/{resource.lstrip('/')}"
|
||||
if query:
|
||||
url = f"{url}?{urllib.parse.urlencode(query)}"
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={"Accept": "application/json", "User-Agent": self.user_agent},
|
||||
)
|
||||
started = time.monotonic()
|
||||
status = 0
|
||||
try:
|
||||
with self.opener.open(request, timeout=self.timeout) as response:
|
||||
status = response.status
|
||||
body = response.read(MAX_RESPONSE_BYTES + 1)
|
||||
except urllib.error.HTTPError as error:
|
||||
status = error.code
|
||||
body = error.read(MAX_RESPONSE_BYTES + 1)
|
||||
finally:
|
||||
self.last_request = time.monotonic()
|
||||
elapsed_ms = round((time.monotonic() - started) * 1000)
|
||||
if len(body) > MAX_RESPONSE_BYTES:
|
||||
raise QualificationError(f"{case}: response exceeded {MAX_RESPONSE_BYTES} bytes")
|
||||
if status not in expected:
|
||||
raise QualificationError(f"{case}: expected HTTP {expected}, received {status}")
|
||||
if not body:
|
||||
payload: dict = {}
|
||||
else:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError as error:
|
||||
raise QualificationError(f"{case}: response was not JSON") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise QualificationError(f"{case}: response root was not an object")
|
||||
count = len(payload.get("recordings", [])) if isinstance(payload.get("recordings"), list) else None
|
||||
self.observations.append(Observation(case, status, elapsed_ms, len(body), count))
|
||||
return payload
|
||||
|
||||
|
||||
def require_text(payload: dict, field: str, case: str) -> str:
|
||||
value = payload.get(field)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise QualificationError(f"{case}: required field {field!r} was absent")
|
||||
return value
|
||||
|
||||
|
||||
def qualify(client: Client) -> dict:
|
||||
searches = (
|
||||
("base-edition", "Sunroof", "Nicky Youre", None),
|
||||
("featured-credit", "Kiss Me More", "Doja Cat", "multiple-credits"),
|
||||
("versioned-title", "Dream A Little Dream Of Me", "Ella Fitzgerald", None),
|
||||
)
|
||||
seed = None
|
||||
for case, title, artist, expectation in searches:
|
||||
payload = client.get(
|
||||
f"search-{case}",
|
||||
"recording/",
|
||||
{"query": f'recording:"{title}" AND artist:"{artist}"', "fmt": "json", "limit": "10"},
|
||||
)
|
||||
recordings = payload.get("recordings")
|
||||
if not isinstance(recordings, list) or not recordings:
|
||||
raise QualificationError(f"search-{case}: no recording candidates returned")
|
||||
if expectation == "multiple-credits" and not any(
|
||||
len(item.get("artist-credit") or []) > 1 for item in recordings
|
||||
):
|
||||
raise QualificationError(f"search-{case}: no multi-artist credit returned")
|
||||
if seed is None:
|
||||
seed = next((item for item in recordings if item.get("releases") and item.get("artist-credit")), recordings[0])
|
||||
|
||||
recording_id = require_text(seed, "id", "recording-seed")
|
||||
recording = client.get(
|
||||
"recording-detail",
|
||||
f"recording/{recording_id}",
|
||||
{"fmt": "json", "inc": "artists+releases+isrcs+aliases+genres"},
|
||||
)
|
||||
require_text(recording, "title", "recording-detail")
|
||||
credits = recording.get("artist-credit") or seed.get("artist-credit") or []
|
||||
releases = recording.get("releases") or seed.get("releases") or []
|
||||
artist_id = next((credit.get("artist", {}).get("id") for credit in credits if credit.get("artist", {}).get("id")), None)
|
||||
release_id = next((release.get("id") for release in releases if release.get("id")), None)
|
||||
if not artist_id or not release_id:
|
||||
raise QualificationError("recording-detail: artist/release hierarchy was incomplete")
|
||||
|
||||
artist = client.get("artist-detail", f"artist/{artist_id}", {"fmt": "json", "inc": "aliases"})
|
||||
require_text(artist, "name", "artist-detail")
|
||||
release = client.get(
|
||||
"release-detail",
|
||||
f"release/{release_id}",
|
||||
{"fmt": "json", "inc": "artist-credits+release-groups+recordings+media+labels"},
|
||||
)
|
||||
release_group = release.get("release-group") or {}
|
||||
release_group_id = require_text(release_group, "id", "release-detail")
|
||||
media = release.get("media")
|
||||
if not isinstance(media, list) or not media:
|
||||
raise QualificationError("release-detail: media/track hierarchy was absent")
|
||||
group = client.get(
|
||||
"release-group-detail",
|
||||
f"release-group/{release_group_id}",
|
||||
{"fmt": "json", "inc": "artist-credits+aliases"},
|
||||
)
|
||||
require_text(group, "title", "release-group-detail")
|
||||
client.get(
|
||||
"missing-recording",
|
||||
"recording/ffffffff-ffff-4fff-8fff-ffffffffffff",
|
||||
{"fmt": "json"},
|
||||
expected=(404,),
|
||||
)
|
||||
return {
|
||||
"endpoint": client.base_url,
|
||||
"sourceRevision": "brainzmash:ws2",
|
||||
"passed": True,
|
||||
"requestCount": len(client.observations),
|
||||
"observations": [observation.__dict__ for observation in client.observations],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--user-agent", default=os.environ.get("BRAINZMASH_USER_AGENT", ""))
|
||||
parser.add_argument("--timeout", type=float, default=30)
|
||||
parser.add_argument("--interval-ms", type=int, default=1000)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.interval_ms < 100:
|
||||
raise QualificationError("The live request interval must be at least 100 ms.")
|
||||
result = qualify(Client(args.base_url, args.user_agent, args.timeout, args.interval_ms))
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
except (QualificationError, urllib.error.URLError) as error:
|
||||
print(f"BrainzMash qualification failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1264,6 +1264,10 @@ check_external_provider_case() {
|
||||
echo "functional-and-security-checks"
|
||||
check_dashboard_session
|
||||
check_public_code "public bootstrap" "200" "$ALLSTARR_BASE/System/Info/Public"
|
||||
compare_projection "public identity parity" \
|
||||
"$DIRECT_BASE/System/Info/Public" \
|
||||
"$ALLSTARR_BASE/System/Info/Public" \
|
||||
'del(.LocalAddress)'
|
||||
direct_version="$(curl -fsS --max-time "$TIMEOUT_SECONDS" "$DIRECT_BASE/System/Info/Public" |
|
||||
jq -r '.Version // empty' || true)"
|
||||
allstarr_version="$(curl -fsS --max-time "$TIMEOUT_SECONDS" "$ALLSTARR_BASE/System/Info/Public" |
|
||||
|
||||
@@ -32,14 +32,26 @@ def socket_url(base_url: str, device_id: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def resolve_user_id(base_url: str, token: str) -> str:
|
||||
def authorization_header(token: str, device_id: str, user_id: str = "") -> str:
|
||||
user = f', UserId="{user_id}"' if user_id else ""
|
||||
return (
|
||||
'MediaBrowser Client="AllstarrLiveSmoke", Device="Qualification", '
|
||||
f'DeviceId="{device_id}", Version="1"{user}, Token="{token}"'
|
||||
)
|
||||
|
||||
|
||||
def resolve_user_id(base_url: str, token: str, run_id: str) -> str:
|
||||
configured = os.environ.get("JELLYFIN_USER_ID", "").strip()
|
||||
if configured:
|
||||
user_id = configured
|
||||
else:
|
||||
request = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}/Users",
|
||||
headers={"X-Emby-Token": token},
|
||||
headers={
|
||||
"Authorization": authorization_header(
|
||||
token, f"allstarr-ws-{run_id}-resolve"
|
||||
)
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
users = json.load(response)
|
||||
@@ -62,15 +74,13 @@ def qualify(
|
||||
label: str, base_url: str, token: str, user_id: str, run_id: str
|
||||
) -> set[str]:
|
||||
device_id = f"allstarr-ws-{run_id}-{label}"
|
||||
authorization = (
|
||||
'MediaBrowser Client="AllstarrLiveSmoke", Device="Qualification", '
|
||||
f'DeviceId="{device_id}", Version="1", UserId="{user_id}", Token="{token}"'
|
||||
)
|
||||
authorization = authorization_header(token, device_id, user_id)
|
||||
started = time.monotonic()
|
||||
message_types: set[str] = set()
|
||||
with connect(
|
||||
socket_url(base_url, device_id),
|
||||
additional_headers={"X-Emby-Authorization": authorization},
|
||||
additional_headers={"Authorization": authorization},
|
||||
proxy=None,
|
||||
open_timeout=10,
|
||||
close_timeout=5,
|
||||
) as socket:
|
||||
@@ -90,7 +100,7 @@ def qualify(
|
||||
).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Emby-Authorization": authorization,
|
||||
"Authorization": authorization,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
@@ -121,14 +131,14 @@ def qualify(
|
||||
|
||||
|
||||
def reject_invalid_token(label: str, base_url: str, run_id: str) -> None:
|
||||
authorization = (
|
||||
'MediaBrowser Client="AllstarrLiveSmoke", Device="Qualification", '
|
||||
f'DeviceId="allstarr-ws-{run_id}-invalid", Version="1", Token="invalid"'
|
||||
authorization = authorization_header(
|
||||
"invalid", f"allstarr-ws-{run_id}-invalid"
|
||||
)
|
||||
try:
|
||||
with connect(
|
||||
socket_url(base_url, f"allstarr-ws-{run_id}-invalid"),
|
||||
additional_headers={"X-Emby-Authorization": authorization},
|
||||
additional_headers={"Authorization": authorization},
|
||||
proxy=None,
|
||||
open_timeout=10,
|
||||
):
|
||||
pass
|
||||
@@ -149,7 +159,7 @@ def main() -> int:
|
||||
direct_base = os.environ.get("DIRECT_BASE", "https://jellyfin.joshpatra.me")
|
||||
allstarr_base = os.environ.get("ALLSTARR_BASE", "https://jfm.joshpatra.me")
|
||||
run_id = secrets.token_hex(6)
|
||||
user_id = resolve_user_id(direct_base, token)
|
||||
user_id = resolve_user_id(direct_base, token, run_id)
|
||||
|
||||
direct_types = qualify("direct", direct_base, token, user_id, run_id)
|
||||
allstarr_types = qualify("allstarr", allstarr_base, token, user_id, run_id)
|
||||
|
||||
+24
-18
@@ -1895,18 +1895,15 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-dialog-close {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.playlist-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
.playlist-detail-dialog > .playlist-hero {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: flex-start;
|
||||
min-height: 0;
|
||||
gap: 0.85rem;
|
||||
justify-content: normal;
|
||||
border-bottom: 1px solid var(--color-edge);
|
||||
padding: var(--surface-gutter);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.playlist-feedback {
|
||||
@@ -2212,11 +2209,16 @@
|
||||
background: var(--route-color);
|
||||
}
|
||||
|
||||
.playlist-hero {
|
||||
.playlist-hero-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-height: 0;
|
||||
gap: 0.85rem;
|
||||
padding: 0.9rem 3.5rem 0.9rem 1rem;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.playlist-dialog-close {
|
||||
position: static;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.playlist-detail-dialog > .playlist-hero,
|
||||
@@ -2268,7 +2270,6 @@
|
||||
min-height: 2.35rem;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: auto;
|
||||
padding-inline: 0.8rem;
|
||||
}
|
||||
|
||||
@@ -2966,8 +2967,13 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.playlist-hero {
|
||||
align-items: center;
|
||||
.playlist-detail-dialog > .playlist-hero {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.playlist-hero-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.playlist-row {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export type Session = {
|
||||
authenticated: boolean;
|
||||
backend: string;
|
||||
features?: {
|
||||
intelligence: boolean;
|
||||
};
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -152,6 +155,7 @@ export type NowPlayingItem = {
|
||||
catalogProviderId?: string;
|
||||
sourceConfirmed?: boolean;
|
||||
cached?: boolean;
|
||||
routeReason?: string | null;
|
||||
artworkUrl?: string | null;
|
||||
positionSeconds: number;
|
||||
durationSeconds?: number | null;
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
<ProviderMark id={item.providerId} definition={providerDefinition(item.providerId)} />
|
||||
<span>
|
||||
<strong>{providerName(item.providerId)}</strong>
|
||||
<small>{item.sourceConfirmed ? (item.cached ? "Cached from" : "Playing from") : "Catalog source · playback unconfirmed"}{item.sourceConfirmed && item.providerAccountName ? ` · ${item.providerAccountName}` : ""}</small>
|
||||
<small title={item.routeReason ?? undefined}>{item.sourceConfirmed ? (item.cached ? "Cached from" : "Playing from") : "Catalog source · playback unconfirmed"}{item.sourceConfirmed && item.providerAccountName ? ` · ${item.providerAccountName}` : ""}</small>
|
||||
</span>
|
||||
</span>
|
||||
<span class="scrobble-state" class:complete={item.scrobbled}>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
playableProviderIds,
|
||||
providerResultCounts,
|
||||
rankedTargets,
|
||||
routingPreference,
|
||||
scoreComponents,
|
||||
} from "$lib/mappings";
|
||||
import { formatDuration } from "$lib/playlists";
|
||||
@@ -267,8 +268,8 @@
|
||||
definition={provider(candidateProvider(candidate))}
|
||||
/>
|
||||
{providerName(candidateProvider(candidate))}
|
||||
{#if candidate.components?.priorityWindow}
|
||||
<span>· {percent(candidate.components.priorityWindow)} priority window</span>
|
||||
{#if routingPreference(candidate.components)}
|
||||
<span>· {routingPreference(candidate.components)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -304,6 +305,7 @@
|
||||
<dl>
|
||||
<div><dt>Candidate ID</dt><dd>{candidate.libraryTrackId || candidate.backendItemId || candidateExternalId(candidate) || "—"}</dd></div>
|
||||
<div><dt>Raw confidence</dt><dd>{percent(candidate.confidence)}</dd></div>
|
||||
{#if routingPreference(candidate.components)}<div><dt>Routing preference</dt><dd>{routingPreference(candidate.components)}</dd></div>{/if}
|
||||
{#each candidateFacts(candidate) as [label, value]}
|
||||
<div><dt>{label}</dt><dd>{value}</dd></div>
|
||||
{/each}
|
||||
@@ -382,8 +384,8 @@
|
||||
definition={provider(target.externalProvider || "")}
|
||||
/>
|
||||
{providerName(target.externalProvider)}
|
||||
{#if target.components?.priorityWindow}
|
||||
<span>· {percent(target.components.priorityWindow)} priority window</span>
|
||||
{#if routingPreference(target.components)}
|
||||
<span>· {routingPreference(target.components)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<strong>{target.title}</strong>
|
||||
@@ -397,17 +399,18 @@
|
||||
<span class="target-score">
|
||||
<strong>{percent(target.confidence)}</strong>
|
||||
<small>confidence</small>
|
||||
<small>rank #{results.indexOf(target) + 1}</small>
|
||||
<small>score rank #{results.indexOf(target) + 1}</small>
|
||||
</span>
|
||||
</button>
|
||||
<details class="target-evidence">
|
||||
<summary class="disclosure-summary compact"><span class="disclosure-label"><strong>Evidence for {target.title}</strong></span></summary>
|
||||
<dl>
|
||||
<div><dt>Rank</dt><dd>#{results.indexOf(target) + 1}</dd></div>
|
||||
<div><dt>Score rank</dt><dd>#{results.indexOf(target) + 1}</dd></div>
|
||||
<div><dt>Candidate ID</dt><dd>{target.externalId || target.backendItemId || target.id}</dd></div>
|
||||
<div><dt>Raw confidence</dt><dd>{percent(target.confidence)}</dd></div>
|
||||
{#if target.isrc}<div><dt>ISRC</dt><dd>{target.isrc}</dd></div>{/if}
|
||||
{#each Object.entries(target.components ?? {}) as [name, value]}
|
||||
{#if routingPreference(target.components)}<div><dt>Routing preference</dt><dd>{routingPreference(target.components)}</dd></div>{/if}
|
||||
{#each scoreComponents({ components: target.components }) as [name, value]}
|
||||
<div><dt>{evidenceLabel(name)}</dt><dd>{percent(value)}</dd></div>
|
||||
{/each}
|
||||
{#each target.reasons ?? [] as reason}<div><dt>Reason</dt><dd>{evidenceLabel(reason)}</dd></div>{/each}
|
||||
|
||||
@@ -49,15 +49,14 @@
|
||||
try {
|
||||
data = await home.jobs();
|
||||
const next = selectJob(data);
|
||||
if (observedJobId === next?.id &&
|
||||
const reachedTerminal = observedJobId === next?.id &&
|
||||
observedState && ["Pending", "Running", "Deferred"].includes(observedState) &&
|
||||
!["Pending", "Running", "Deferred"].includes(next.state)) {
|
||||
await onTerminal();
|
||||
}
|
||||
!["Pending", "Running", "Deferred"].includes(next?.state ?? "");
|
||||
if (next) {
|
||||
observedJobId = next.id;
|
||||
observedState = next.state;
|
||||
}
|
||||
if (reachedTerminal) await onTerminal();
|
||||
} catch {
|
||||
// The parent route retains its authoritative playlist state when job diagnostics fail.
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
let detailLoading = $state(false);
|
||||
let detailError = $state("");
|
||||
let refreshing = $state(false);
|
||||
let passiveRefreshing = $state(false);
|
||||
let error = $state("");
|
||||
let degraded = $state("");
|
||||
let feedback = $state("");
|
||||
@@ -76,6 +77,7 @@
|
||||
let detailReturnFocus: HTMLElement | null = null;
|
||||
let matchWasOpen = false;
|
||||
let matchReturnFocus: HTMLElement | null = null;
|
||||
let refreshInFlight = false;
|
||||
let page = $state(1);
|
||||
let addOpen = $state(false);
|
||||
let operationJobId = $state("");
|
||||
@@ -297,43 +299,50 @@
|
||||
await loadDetails(selectedId, undefined, mode as typeof viewMode);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (refreshing) {
|
||||
refreshQueued = true;
|
||||
async function refresh(passive = false) {
|
||||
if (refreshInFlight) {
|
||||
if (!passive) refreshQueued = true;
|
||||
return;
|
||||
}
|
||||
refreshing = true;
|
||||
refreshInFlight = true;
|
||||
if (passive) passiveRefreshing = true;
|
||||
else refreshing = true;
|
||||
error = "";
|
||||
degraded = "";
|
||||
const [linksResult, schemaResult] = await Promise.allSettled([
|
||||
playlistLinks.list(),
|
||||
home.schema(),
|
||||
]);
|
||||
if (linksResult.status === "rejected") {
|
||||
error =
|
||||
linksResult.reason instanceof Error
|
||||
? linksResult.reason.message
|
||||
: "Playlists are unavailable.";
|
||||
} else {
|
||||
playlists = linksResult.value.playlistLinks;
|
||||
const nextId = playlists.some((playlist) => playlist.id === selectedId) ? selectedId : "";
|
||||
if (nextId && (detailOpen || initialId === nextId)) await loadDetails(nextId);
|
||||
else {
|
||||
selectedId = "";
|
||||
details = null;
|
||||
try {
|
||||
const [linksResult, schemaResult] = await Promise.allSettled([
|
||||
playlistLinks.list(),
|
||||
home.schema(),
|
||||
]);
|
||||
if (linksResult.status === "rejected") {
|
||||
error =
|
||||
linksResult.reason instanceof Error
|
||||
? linksResult.reason.message
|
||||
: "Playlists are unavailable.";
|
||||
} else {
|
||||
playlists = linksResult.value.playlistLinks;
|
||||
const nextId = playlists.some((playlist) => playlist.id === selectedId) ? selectedId : "";
|
||||
if (nextId && (detailOpen || initialId === nextId)) await loadDetails(nextId);
|
||||
else {
|
||||
selectedId = "";
|
||||
details = null;
|
||||
}
|
||||
}
|
||||
if (schemaResult.status === "fulfilled") providers = schemaResult.value.providers;
|
||||
else degraded = "Provider names and artwork are temporarily unavailable.";
|
||||
loading = false;
|
||||
} finally {
|
||||
refreshInFlight = false;
|
||||
if (passive) passiveRefreshing = false;
|
||||
else refreshing = false;
|
||||
if (refreshQueued) {
|
||||
refreshQueued = false;
|
||||
void refresh();
|
||||
}
|
||||
}
|
||||
if (schemaResult.status === "fulfilled") providers = schemaResult.value.providers;
|
||||
else degraded = "Provider names and artwork are temporarily unavailable.";
|
||||
loading = false;
|
||||
refreshing = false;
|
||||
if (refreshQueued) {
|
||||
refreshQueued = false;
|
||||
void refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const refreshScheduler = createRefreshScheduler(refresh);
|
||||
const refreshScheduler = createRefreshScheduler(() => refresh(true));
|
||||
const scheduleRefresh = refreshScheduler.schedule;
|
||||
|
||||
async function run(name: "sync" | "toggle") {
|
||||
@@ -475,7 +484,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="playlist-layout" aria-busy={refreshing}>
|
||||
<section class="playlist-layout" aria-busy={refreshing || passiveRefreshing}>
|
||||
<article class="panel playlist-list">
|
||||
<header class="panel-heading playlist-toolbar">
|
||||
<div>
|
||||
@@ -623,27 +632,29 @@
|
||||
<span>Allstarr · via {providerName(details.targetProtocol)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Close class="icon-button playlist-dialog-close" aria-label="Close playlist details"><X size={18} aria-hidden="true" /></Dialog.Close>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={`${buttonVariants({ variant: "secondary" })} playlist-actions-trigger`}>
|
||||
Actions <ChevronDown size={16} aria-hidden="true" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="bits-menu" sideOffset={6} align="end">
|
||||
<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action) || !selected.enabled} onSelect={() => void run("sync")}>{selected.importMode === "oneTime" ? "Rebuild from imported copy" : "Update playlist now"}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action)} onSelect={() => { detailOpen = false; rematchOpen = true; }}>Review account rematch</DropdownMenu.Item>
|
||||
{#if selected.importMode === "linked"}<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action)} onSelect={() => void refreshSources([selected.id])}>Refresh source</DropdownMenu.Item>{/if}
|
||||
{#if selected.sourceUpdateAvailable}
|
||||
<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action)} onSelect={() => sourceUpdateOpen = true}>
|
||||
Preview changes to {providerName(selected.sourceProviderId)}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action)} onSelect={() => settingsOpen = true}>Edit settings</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action)} onSelect={() => void run("toggle")}>{selected.enabled ? "Pause" : "Resume"}</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
<div class="playlist-hero-actions">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={`${buttonVariants({ variant: "secondary" })} playlist-actions-trigger`}>
|
||||
Actions <ChevronDown size={16} aria-hidden="true" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="bits-menu" sideOffset={6} align="end">
|
||||
<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action) || !selected.enabled} onSelect={() => void run("sync")}>{selected.importMode === "oneTime" ? "Rebuild from imported copy" : "Update playlist now"}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action)} onSelect={() => { detailOpen = false; rematchOpen = true; }}>Review account rematch</DropdownMenu.Item>
|
||||
{#if selected.importMode === "linked"}<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action)} onSelect={() => void refreshSources([selected.id])}>Refresh source</DropdownMenu.Item>{/if}
|
||||
{#if selected.sourceUpdateAvailable}
|
||||
<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action)} onSelect={() => sourceUpdateOpen = true}>
|
||||
Preview changes to {providerName(selected.sourceProviderId)}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action)} onSelect={() => settingsOpen = true}>Edit settings</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="bits-menu-item" disabled={Boolean(action)} onSelect={() => void run("toggle")}>{selected.enabled ? "Pause" : "Resume"}</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
<Dialog.Close class="icon-button playlist-dialog-close" aria-label="Close playlist details"><X size={18} aria-hidden="true" /></Dialog.Close>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="playlist-view-switcher">
|
||||
|
||||
@@ -10,10 +10,19 @@ import {
|
||||
providerResultCounts,
|
||||
rankedTargets,
|
||||
reviewStateLabel,
|
||||
routingPreference,
|
||||
scoreComponents,
|
||||
} from "./mappings";
|
||||
|
||||
describe("mapping review presentation", () => {
|
||||
it("separates routing priority from confidence evidence", () => {
|
||||
const components = { title: 1, priorityWindow: 0.07, routingPriority: 0, acceptanceQualified: 1 };
|
||||
expect(scoreComponents({ components })).toEqual([["title", 1]]);
|
||||
expect(routingPreference(components)).toBe("Local first");
|
||||
expect(routingPreference({ ...components, routingPriority: 2 })).toBe("Streaming priority 2");
|
||||
expect(routingPreference({ ...components, acceptanceQualified: 0 })).toBe("7% tentative window");
|
||||
expect(routingPreference(undefined)).toBe("");
|
||||
});
|
||||
it("compares simple artwork fingerprints", () => {
|
||||
const pixels = new Uint8ClampedArray(9 * 8 * 4);
|
||||
for (let row = 0; row < 8; row += 1)
|
||||
|
||||
@@ -16,10 +16,20 @@ export function percent(value?: number | null) {
|
||||
|
||||
export function scoreComponents(candidate: MatchCandidate) {
|
||||
return Object.entries(candidate.components ?? {})
|
||||
.filter((entry): entry is [string, number] => Number.isFinite(entry[1]))
|
||||
.filter((entry): entry is [string, number] =>
|
||||
!["priorityWindow", "routingPriority", "acceptanceQualified"].includes(entry[0]) && Number.isFinite(entry[1]))
|
||||
.toSorted((left, right) => right[1] - left[1]);
|
||||
}
|
||||
|
||||
export function routingPreference(components?: Record<string, number> | null) {
|
||||
if (components?.acceptanceQualified === 1) {
|
||||
const priority = components.routingPriority;
|
||||
return priority === 0 ? "Local first" : priority > 0 && priority < 2147483647
|
||||
? `Streaming priority ${priority}` : "Meets acceptance checks";
|
||||
}
|
||||
return components?.priorityWindow ? `${percent(components.priorityWindow)} tentative window` : "";
|
||||
}
|
||||
|
||||
export function providerResultCounts(targets: MatchTarget[]) {
|
||||
return [...targets.reduce((counts, target) => {
|
||||
const providerId = target.externalProvider || "local";
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{ href: "#/activity", label: "Activity", mobileLabel: "Activity", icon: "activity", mobile: true },
|
||||
{ href: "#/settings", label: "Settings", mobileLabel: "Settings", icon: "settings", mobile: false },
|
||||
];
|
||||
// Keep existing Intelligence deep links usable while deferring its release navigation.
|
||||
// Intelligence remains available only in the explicit development composition.
|
||||
const navigationDestinations = destinations.filter((item) => item.href !== "#/intelligence");
|
||||
const mobileDestinations = navigationDestinations.filter((item) => item.mobile);
|
||||
const moreDestinations = navigationDestinations.filter((item) => !item.mobile);
|
||||
@@ -163,7 +163,9 @@
|
||||
return import("$lib/components/DownloadsView.svelte");
|
||||
}
|
||||
if (path === "/activity") return import("$lib/components/EventLogView.svelte");
|
||||
if (path === "/intelligence") return import("$lib/components/IntelligenceView.svelte");
|
||||
if (path === "/intelligence" && session?.features?.intelligence === true) {
|
||||
return import("$lib/components/IntelligenceView.svelte");
|
||||
}
|
||||
if (path.startsWith("/integrations")) return import("$lib/components/IntegrationsView.svelte");
|
||||
if (path.startsWith("/settings")) return import("$lib/components/SettingsView.svelte");
|
||||
}
|
||||
@@ -178,6 +180,7 @@
|
||||
|
||||
$effect(() => {
|
||||
const path = route;
|
||||
session?.features?.intelligence;
|
||||
const loader = viewLoader(path);
|
||||
const key = viewKey(path);
|
||||
if (ActiveView && loadedViewKey === key) {
|
||||
|
||||
+144
-10
@@ -139,7 +139,7 @@ const schema = {
|
||||
{
|
||||
id: "general", label: "General", fields: [
|
||||
{ key: "AUDIO_QUALITY", label: "Audio quality", type: "audio-quality", valuePath: "audio.quality" },
|
||||
{ key: "MATCHING_LOCAL_PREFERENCE_PERCENT", label: "Local match window", type: "number", valuePath: "matching.localPreferencePercent", min: 0, max: 20, helpText: "A local candidate wins when it is no more than this many confidence points behind the strongest result. Default: 7%." },
|
||||
{ key: "MATCHING_LOCAL_PREFERENCE_PERCENT", label: "Tentative local window", type: "number", valuePath: "matching.localPreferencePercent", min: 0, max: 20, helpText: "Only used when no candidate qualifies for automatic acceptance. Accepted matches always use local first, then Streaming priority." },
|
||||
{ key: "STORAGE_MODE", label: "Storage mode", type: "select", valuePath: "library.storageMode", options: ["Permanent", "Cache"] },
|
||||
{ key: "PublicUrl", label: "Public URL", type: "text", valuePath: "deployment.url", ownership: "deployment", readOnly: true },
|
||||
],
|
||||
@@ -162,6 +162,7 @@ const schema = {
|
||||
const responses: Record<string, unknown> = {
|
||||
"/api/admin/auth/me": {
|
||||
authenticated: true, backend: "Jellyfin",
|
||||
features: { intelligence: true },
|
||||
user: {
|
||||
id: "user", name: "Tester", isAdministrator: true,
|
||||
avatarUrl: "/api/admin/auth/me/avatar?user=user",
|
||||
@@ -514,6 +515,7 @@ async function mockApi(page: Page, options: {
|
||||
fail?: string[];
|
||||
audioMuseUnavailable?: boolean;
|
||||
audioMuseDisabledAccount?: boolean;
|
||||
intelligenceEnabled?: boolean;
|
||||
} = {}) {
|
||||
await page.route("**/fonts/**", (route) => route.fulfill({ status: 204 }));
|
||||
await page.route("**/images/providers/**", (route) => route.fulfill({
|
||||
@@ -541,6 +543,12 @@ async function mockApi(page: Page, options: {
|
||||
return;
|
||||
}
|
||||
let body = responses[`${url.pathname}${url.search}`] ?? responses[url.pathname];
|
||||
if (url.pathname === "/api/admin/auth/me" && options.intelligenceEnabled === false) {
|
||||
body = {
|
||||
...(structuredClone(body) as Record<string, unknown>),
|
||||
features: { intelligence: false },
|
||||
};
|
||||
}
|
||||
if (url.pathname === "/api/admin/provider-accounts" && route.request().method() === "GET" &&
|
||||
options.audioMuseDisabledAccount) {
|
||||
const accountResponse = structuredClone(body) as typeof responses["/api/admin/provider-accounts"];
|
||||
@@ -622,7 +630,7 @@ async function mockApi(page: Page, options: {
|
||||
id: "jellyfin-kiss-me-more", backendItemId: "jellyfin-kiss-me-more",
|
||||
title: "Kiss Me More", artist: "Doja Cat feat. SZA", album: "Planet Her",
|
||||
durationMilliseconds: 208_000, confidence: 0.91,
|
||||
components: { priorityWindow: 0.07 },
|
||||
components: { priorityWindow: 0.07, routingPriority: 0, acceptanceQualified: 1 },
|
||||
}],
|
||||
};
|
||||
if (url.pathname === "/api/admin/track-matches/targets/provider")
|
||||
@@ -897,6 +905,21 @@ const stateRoutes = [
|
||||
["#/settings/general", "Settings", "Loading Settings", "/api/admin/ui/schema", ["/api/admin/ui/schema"]],
|
||||
] as const;
|
||||
|
||||
test("core release does not expose the deferred Intelligence route", async ({ page }) => {
|
||||
const intelligenceRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname.startsWith("/api/admin/intelligence")) {
|
||||
intelligenceRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
await mockApi(page, { intelligenceEnabled: false });
|
||||
|
||||
await page.goto("#/intelligence");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "This Allstarr view does not exist." })).toBeVisible();
|
||||
expect(intelligenceRequests).toEqual([]);
|
||||
});
|
||||
|
||||
for (const viewport of viewports) {
|
||||
test.describe(`${viewport.width}x${viewport.height}`, () => {
|
||||
test.use({ viewport, colorScheme: process.env.ALLSTARR_SCREENSHOT_THEME === "dark" ? "dark" : "light" });
|
||||
@@ -2703,7 +2726,7 @@ test("Tentative mappings sort by confidence and deep links open review", async (
|
||||
id: "jellyfin-kiss-me-more", backendItemId: "jellyfin-kiss-me-more",
|
||||
title: "Kiss Me More", artist: "Doja Cat feat. SZA", album: "Planet Her",
|
||||
durationMilliseconds: 208_000, confidence: 0.91,
|
||||
components: { priorityWindow: 0.07 },
|
||||
components: { priorityWindow: 0.07, routingPriority: 0, acceptanceQualified: 1 },
|
||||
}] : [],
|
||||
}),
|
||||
});
|
||||
@@ -2719,7 +2742,7 @@ test("Tentative mappings sort by confidence and deep links open review", async (
|
||||
externalProvider: "lumen-audio", title: "Kiss Me More",
|
||||
artist: "Doja Cat feat. SZA", album: "Planet Her",
|
||||
durationMilliseconds: 208_000, confidence: 0.94,
|
||||
components: { priorityWindow: 0.05 },
|
||||
components: { priorityWindow: 0.05, routingPriority: 1, acceptanceQualified: 1 },
|
||||
}],
|
||||
providers: ["lumen-audio", "qobuz"],
|
||||
}),
|
||||
@@ -2735,7 +2758,7 @@ test("Tentative mappings sort by confidence and deep links open review", async (
|
||||
await expect(dialog.locator(".candidate-card .mapping-art > span").first()).toBeVisible();
|
||||
await expect(
|
||||
dialog.locator(".automatic-candidates .candidate-provider")
|
||||
.filter({ hasText: "Jellyfin · 7% priority window" }),
|
||||
.filter({ hasText: "Jellyfin · 7% tentative window" }),
|
||||
).toBeVisible();
|
||||
await expect(dialog.locator(".automatic-candidates .candidate-confidence").getByText("82%"))
|
||||
.toHaveCount(2);
|
||||
@@ -2750,8 +2773,8 @@ test("Tentative mappings sort by confidence and deep links open review", async (
|
||||
await expect(dialog.getByRole("button", { name: /Qobuz/ })).toHaveCount(0);
|
||||
await expect(dialog.getByText("Planet Her")).toHaveCount(2);
|
||||
await expect(dialog.locator(".target-results > button .target-score")).toHaveCount(2);
|
||||
await expect(dialog.locator(".target-results").getByText("· 7% priority window")).toHaveCount(1);
|
||||
await expect(dialog.locator(".target-results").getByText("· 5% priority window")).toHaveCount(1);
|
||||
await expect(dialog.locator(".target-results").getByText("· Local first")).toHaveCount(1);
|
||||
await expect(dialog.locator(".target-results").getByText("· Streaming priority 1")).toHaveCount(1);
|
||||
await expect(dialog.locator(".target-results")).toHaveCSS("overflow-y", "visible");
|
||||
await expect(dialog.locator(":scope > footer")).toHaveCSS("position", "sticky");
|
||||
await expect(
|
||||
@@ -2762,7 +2785,7 @@ test("Tentative mappings sort by confidence and deep links open review", async (
|
||||
dialog.locator(".target-results > button").filter({ hasText: "Lumen Audio" })
|
||||
.locator(".target-score").getByText("94%"),
|
||||
).toBeVisible();
|
||||
await expect(dialog.getByText("rank #1")).toBeVisible();
|
||||
await expect(dialog.getByText("score rank #1")).toBeVisible();
|
||||
await dialog.locator(".candidate-card").first().getByText("Full scoring evidence").click();
|
||||
await expect(dialog.locator(".candidate-card").first().getByText("Candidate ID")).toBeVisible();
|
||||
await expect(dialog.locator(".candidate-card").first().getByText("Artist overlap")).toBeVisible();
|
||||
@@ -2770,7 +2793,7 @@ test("Tentative mappings sort by confidence and deep links open review", async (
|
||||
await expect(dialog.locator(".candidate-card").first().getByText("Apple Music – GAMDL track ID")).toBeVisible();
|
||||
await dialog.locator(".candidate-card").last().getByText("Full scoring evidence").click();
|
||||
await expect(dialog.locator(".candidate-card").last().getByRole("term")
|
||||
.filter({ hasText: /priority window/i }))
|
||||
.filter({ hasText: "Routing preference" }))
|
||||
.toBeVisible();
|
||||
await dialog.getByLabel("Search local library and playable providers").fill("No local copy");
|
||||
await dialog.getByRole("button", { name: "Search", exact: true }).click();
|
||||
@@ -3773,7 +3796,7 @@ test("Audio quality supports keyboard changes, provider outcomes, save, and relo
|
||||
await page.getByText("Music source quality details", { exact: true }).click();
|
||||
await expect(page.getByText("Apple Music: AAC 320 kbps")).toBeVisible();
|
||||
await expect(page.getByText("Deezer: MP3 320 kbps")).toBeVisible();
|
||||
await expect(page.getByLabel("Local match window")).toHaveValue("7");
|
||||
await expect(page.getByLabel("Tentative local window")).toHaveValue("7");
|
||||
await page.getByRole("button", { name: "Save playback and matching" }).click();
|
||||
await expect.poll(() => saved).toBe("High");
|
||||
await page.reload();
|
||||
@@ -4240,6 +4263,117 @@ test("Playlist details use a responsive dialog and track rows open mapping revie
|
||||
await expect(page.getByRole("dialog", { name: "Test song" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Playlist dialog keeps Actions and close controls independent at desktop and mobile widths", async ({ page }) => {
|
||||
await mockApi(page);
|
||||
for (const viewport of [{ width: 390, height: 844 }, { width: 1280, height: 800 }]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("#/library/playlists");
|
||||
const opener = page.getByRole("button", { name: "Open Test playlist playlist details", exact: true });
|
||||
await opener.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Test playlist" });
|
||||
const actions = dialog.getByRole("button", { name: "Actions" });
|
||||
const close = dialog.getByRole("button", { name: "Close playlist details" });
|
||||
await expect(actions).toBeVisible();
|
||||
await expect(close).toBeVisible();
|
||||
await expect(close).toHaveAttribute("aria-label", "Close playlist details");
|
||||
|
||||
const geometry = await dialog.evaluate((element) => {
|
||||
const hero = element.querySelector<HTMLElement>(".playlist-hero");
|
||||
const action = element.querySelector<HTMLElement>(".playlist-actions-trigger");
|
||||
const closeButton = element.querySelector<HTMLElement>(".playlist-dialog-close");
|
||||
if (!hero || !action || !closeButton) return null;
|
||||
const heroBounds = hero.getBoundingClientRect();
|
||||
const actionBounds = action.getBoundingClientRect();
|
||||
const closeBounds = closeButton.getBoundingClientRect();
|
||||
const overlap = !(
|
||||
actionBounds.right <= closeBounds.left || closeBounds.right <= actionBounds.left ||
|
||||
actionBounds.bottom <= closeBounds.top || closeBounds.bottom <= actionBounds.top
|
||||
);
|
||||
return {
|
||||
overlap,
|
||||
actionInHero: hero.contains(action),
|
||||
closeInHero: hero.contains(closeButton),
|
||||
actionRight: actionBounds.right,
|
||||
closeLeft: closeBounds.left,
|
||||
heroRight: heroBounds.right,
|
||||
};
|
||||
});
|
||||
expect(geometry).not.toBeNull();
|
||||
expect(geometry?.overlap).toBe(false);
|
||||
expect(geometry?.actionInHero).toBe(true);
|
||||
expect(geometry?.closeInHero).toBe(true);
|
||||
expect(geometry?.actionRight ?? 0).toBeLessThanOrEqual(geometry?.heroRight ?? 0);
|
||||
expect(geometry?.closeLeft ?? 0).toBeGreaterThanOrEqual(0);
|
||||
|
||||
await close.focus();
|
||||
await expect(close).toBeFocused();
|
||||
await actions.click();
|
||||
await expect(page.getByRole("menuitem", { name: "Update playlist now" })).toBeVisible();
|
||||
await expect(close).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await close.click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(opener).toBeFocused();
|
||||
}
|
||||
});
|
||||
|
||||
test("Passive playlist refresh keeps mutation controls enabled while detail reads finish", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 835, height: 762 });
|
||||
await page.addInitScript(() => {
|
||||
class TestEventSource {
|
||||
static instance: TestEventSource | undefined;
|
||||
listeners = new Map<string, EventListener[]>();
|
||||
|
||||
constructor() {
|
||||
TestEventSource.instance = this;
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: EventListener) {
|
||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, "EventSource", { value: TestEventSource });
|
||||
window.__emitAllstarrUpdate = () => {
|
||||
const event = {
|
||||
data: JSON.stringify({ resource: "playlist-links", resourceId: "playlist-link", revision: 2 }),
|
||||
lastEventId: "playlist-links-2",
|
||||
};
|
||||
for (const listener of TestEventSource.instance?.listeners.get("update") ?? [])
|
||||
listener(event as unknown as Event);
|
||||
};
|
||||
});
|
||||
await mockApi(page);
|
||||
const detailRefresh = routeRelease();
|
||||
let detailRequests = 0;
|
||||
await page.route("**/api/admin/playlist-links/playlist-link?*", async (route) => {
|
||||
detailRequests++;
|
||||
if (detailRequests === 2) await detailRefresh.promise;
|
||||
return route.fallback();
|
||||
});
|
||||
let postRequests = 0;
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "POST") postRequests++;
|
||||
});
|
||||
|
||||
await page.goto("#/library/playlists");
|
||||
await page.getByRole("button", { name: "Open Test playlist playlist details" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Test playlist" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect.poll(() => detailRequests).toBe(1);
|
||||
|
||||
await page.evaluate(() => window.__emitAllstarrUpdate?.());
|
||||
await expect.poll(() => detailRequests).toBe(2);
|
||||
await expect(page.getByRole("button", { name: "Refresh playlists" })).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "Review rematch" })).toBeEnabled();
|
||||
expect(postRequests).toBe(0);
|
||||
|
||||
detailRefresh.release();
|
||||
await expect(dialog.getByRole("button", { name: "Close playlist details" })).toBeVisible();
|
||||
});
|
||||
|
||||
for (const viewport of [{ width: 390, height: 844 }, { width: 1280, height: 800 }]) {
|
||||
test(`Playlist sync preserves reading position at ${viewport.width}px`, async ({ page }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
|
||||
Reference in new issue
Block a user