mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-07 22:03:14 -04:00
Compare commits
4
Commits
5584ff4257
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58071e3e2c
|
||
|
|
1a72ecc3cb
|
||
|
|
e9641ac6f6
|
||
|
|
9970352367
|
No files matched your search
@@ -43,7 +43,7 @@ Use Google Material 3 as the interaction and visual grammar, adapted to Allstarr
|
||||
|
||||
- **Home:** current playback and listeners first; source route, scrobble delivery, health, and work follow.
|
||||
- **Library:** playlists, mappings, cached files, and kept files share one task vocabulary and aligned tables.
|
||||
- **Intelligence (deferred):** excluded from first-release navigation. Existing deep links remain usable during development; hiding navigation does not disable backend work or remove data. Its retained workspace owns Overview, History, Import, Discover, Playlists, and Automation. See the release boundary in [the release plan](docs/release-readiness.md).
|
||||
- **Intelligence (deferred):** excluded from first-release navigation. Existing deep links remain usable during development; hiding navigation does not disable backend work or remove data. Its retained workspace owns Overview, History, Import, Discover, Playlists, and Automation.
|
||||
- **Integrations:** Services owns provider configuration and diagnostics. Extensions owns package lifecycle. Accounts and Routing explain their scope in plain language.
|
||||
- **Activity:** outcome, actor, target, duration, and time are primary; technical payloads are progressive detail.
|
||||
- **Settings:** deployment and operator controls only. User-scoped controls stay near the data they affect.
|
||||
|
||||
@@ -70,7 +70,7 @@ Later source updates use `./allstarr.sh update`; the command requires a clean tr
|
||||
- **Activity** explains completed and failed work with correlation details.
|
||||
- **Settings** owns deployment-level behavior, matching, playback, cache policy, maintenance, backup, and recovery.
|
||||
|
||||
Intelligence is deferred from the first release and hidden from navigation. Its development workspace and existing deep links remain available for now; accounts, history, and opt-in background work are unchanged. See the [release scope and remaining gates](docs/release-readiness.md#first-release-scope-decision-2026-09-13).
|
||||
Intelligence is deferred from the first release and hidden from navigation. Its development workspace and existing deep links remain available for now; accounts, history, and opt-in background work are unchanged.
|
||||
|
||||
## Capabilities
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
@@ -56,6 +59,19 @@ public sealed class TrackIdentityServiceTests : IAsyncLifetime
|
||||
_service = new TrackIdentityService(_factory, _storageState, _clock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledCatalog_CreatesIdentityWithoutEnqueuingRemoteWork()
|
||||
{
|
||||
var queue = new Mock<IMusicBrainzCatalogRefreshQueue>(MockBehavior.Strict);
|
||||
var service = new TrackIdentityService(_factory, _storageState, _clock, queue.Object,
|
||||
Options.Create(new MusicBrainzSettings { Enabled = false }));
|
||||
var created = await service.CreateRecordingAsync(Actor(_tenantA, _userA), "disabled-catalog",
|
||||
musicBrainzRecordingId: "16ba7915-2acf-42b2-8c87-ed67090dca91");
|
||||
Assert.True(created.Created);
|
||||
Assert.NotNull(created.Recording.MusicBrainzRecordingId);
|
||||
queue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneCanonicalRecording_LinksManyProvidersAndTranslatesExactly()
|
||||
{
|
||||
@@ -158,6 +174,129 @@ public sealed class TrackIdentityServiceTests : IAsyncLifetime
|
||||
It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task AdditionalExactSignal_EnrichesSameRecordingAndPreservesPinnedRoutes(bool isrcFirst)
|
||||
{
|
||||
const string isrc = "USRC17607839";
|
||||
const string mbid = "11111111-1111-4111-8111-111111111111";
|
||||
var actor = Actor(_tenantA, _userA);
|
||||
var first = await _service.CreateRecordingAsync(actor, "initial-signal",
|
||||
isrcFirst ? isrc : null, isrcFirst ? null : mbid);
|
||||
var link = await _service.LinkAsync(Context(actor, "deezer"), new(
|
||||
first.Recording.Id, Track("deezer", "pinned-track"), ProviderIdentityScope.Catalog,
|
||||
ProviderIdentityVerification.Pinned, "manual-review", 1));
|
||||
var queue = new Mock<IMusicBrainzCatalogRefreshQueue>(MockBehavior.Strict);
|
||||
queue.Setup(item => item.EnqueueRecordingAsync(actor, mbid, "enrich", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DurableJobEnqueueResult(Guid.CreateVersion7(), true));
|
||||
var service = new TrackIdentityService(_factory, _storageState, _clock, queue.Object);
|
||||
|
||||
var enriched = await service.CreateRecordingAsync(actor, "enrich", isrc, mbid);
|
||||
var repeated = await service.CreateRecordingAsync(actor, "enrich", isrc, mbid);
|
||||
|
||||
Assert.False(enriched.Created);
|
||||
Assert.Equal(first.Recording.Id, enriched.Recording.Id);
|
||||
Assert.Equal(isrc, enriched.Recording.Isrc);
|
||||
Assert.Equal(mbid, enriched.Recording.MusicBrainzRecordingId);
|
||||
Assert.Equal(first.Recording.Revision + 1, enriched.Recording.Revision);
|
||||
Assert.Equal(enriched.Recording, repeated.Recording);
|
||||
queue.Verify(item => item.EnqueueRecordingAsync(actor, mbid, "enrich", It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
await using var db = await _factory.CreateDbContextAsync();
|
||||
Assert.False((await db.CanonicalRecordings.SingleAsync()).IsProvisional);
|
||||
var pinned = await db.ProviderTrackIdentities.SingleAsync();
|
||||
Assert.Equal(link.LinkId, pinned.Id);
|
||||
Assert.Equal(first.Recording.Id, pinned.CanonicalRecordingId);
|
||||
Assert.Equal(ProviderIdentityVerification.Pinned, pinned.Verification);
|
||||
Assert.Equal(3, await db.CanonicalCatalogAliases.CountAsync());
|
||||
Assert.Single(await db.AuditEvents.Where(item => item.Outcome == "enriched").ToArrayAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdditionalExactSignal_AliasConflictDoesNotPartiallyEnrichRecording()
|
||||
{
|
||||
const string isrc = "USRC17607839";
|
||||
const string mbid = "11111111-1111-4111-8111-111111111111";
|
||||
var actor = Actor(_tenantA, _userA);
|
||||
var first = await _service.CreateRecordingAsync(actor, "first", isrc);
|
||||
var other = await _service.CreateRecordingAsync(actor, "other");
|
||||
await using (var db = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
db.CanonicalCatalogAliases.Add(new CanonicalCatalogAliasRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = _tenantA,
|
||||
EntityKind = CanonicalCatalogEntityKind.Recording,
|
||||
CanonicalEntityId = other.Recording.Id,
|
||||
Namespace = "musicbrainz",
|
||||
ExternalId = mbid,
|
||||
ExternalIdHash = CanonicalCatalogKeys.Hash(mbid),
|
||||
CreatedAt = _clock.UtcNow,
|
||||
LastSeenAt = _clock.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.CreateRecordingAsync(actor, "conflicting-alias", isrc, mbid));
|
||||
|
||||
await using var verification = await _factory.CreateDbContextAsync();
|
||||
var unchanged = await verification.CanonicalRecordings.SingleAsync(item => item.Id == first.Recording.Id);
|
||||
Assert.Null(unchanged.MusicBrainzRecordingId);
|
||||
Assert.True(unchanged.IsProvisional);
|
||||
Assert.Equal(first.Recording.Revision, unchanged.Revision);
|
||||
Assert.Empty(await verification.AuditEvents.Where(item => item.Outcome == "enriched").ToArrayAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(true, true)]
|
||||
public async Task ConcurrentExactSignals_CoalesceCompatibleWritesAndRejectConflicts(bool existing, bool conflicting)
|
||||
{
|
||||
const string isrc = "USRC17607839";
|
||||
const string mbid = "11111111-1111-4111-8111-111111111111";
|
||||
var actor = Actor(_tenantA, _userA);
|
||||
var original = existing ? await _service.CreateRecordingAsync(actor, "seed", isrc) : null;
|
||||
var options = new DbContextOptionsBuilder<AllstarrDbContext>(_database.Options)
|
||||
.AddInterceptors(new ConcurrentSaveGate()).Options;
|
||||
var service = new TrackIdentityService(new TestDbContextFactory(options), _storageState, _clock);
|
||||
async Task<CanonicalRecordingCreationResult?> Create(string recordingMbid)
|
||||
{
|
||||
try { return await service.CreateRecordingAsync(actor, "concurrent", isrc, recordingMbid); }
|
||||
catch (InvalidOperationException) { return null; }
|
||||
}
|
||||
|
||||
var results = await Task.WhenAll(Create(mbid), Create(conflicting
|
||||
? "22222222-2222-4222-8222-222222222222" : mbid));
|
||||
|
||||
var succeeded = results.OfType<CanonicalRecordingCreationResult>().ToArray();
|
||||
Assert.Equal(conflicting ? 1 : 2, succeeded.Length);
|
||||
await using var db = await _factory.CreateDbContextAsync();
|
||||
var recording = await db.CanonicalRecordings.SingleAsync();
|
||||
Assert.All(succeeded, item => Assert.Equal(recording.Id, item.Recording.Id));
|
||||
if (original != null) Assert.Equal(original.Recording.Id, recording.Id);
|
||||
Assert.Equal(2, await db.CanonicalCatalogAliases.CountAsync());
|
||||
Assert.All(await db.CanonicalCatalogAliases.ToArrayAsync(), item => Assert.Equal(recording.Id, item.CanonicalEntityId));
|
||||
Assert.Equal(existing ? 1 : 0, await db.AuditEvents.CountAsync(item => item.Outcome == "enriched"));
|
||||
}
|
||||
|
||||
private sealed class ConcurrentSaveGate : SaveChangesInterceptor
|
||||
{
|
||||
private int _arrivals;
|
||||
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var arrival = Interlocked.Increment(ref _arrivals);
|
||||
if (arrival == 2) _ready.TrySetResult();
|
||||
if (arrival <= 2) await _ready.Task.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingVerifiedLink_RemainsUnresolvedAndNeverGuesses()
|
||||
{
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using allstarr.Core.Identity;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Jobs;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Protocols;
|
||||
using allstarr.Core.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
@@ -18,6 +21,8 @@ public sealed class LibraryIndexServiceTests : IAsyncLifetime
|
||||
private Guid _identityA;
|
||||
private Guid _identityB;
|
||||
private FakeClock _clock = null!;
|
||||
private TrackIdentityService _identities = null!;
|
||||
private readonly Mock<IMusicBrainzCatalogRefreshQueue> _catalogQueue = new(MockBehavior.Strict);
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
@@ -53,7 +58,8 @@ public sealed class LibraryIndexServiceTests : IAsyncLifetime
|
||||
var state = new DurableStorageState(options);
|
||||
state.Set(DurableStorageReadiness.Ready, "fixture");
|
||||
_clock = new FakeClock(now);
|
||||
_service = new LibraryIndexService(_factory, state, _clock);
|
||||
_identities = new TrackIdentityService(_factory, state, _clock, _catalogQueue.Object);
|
||||
_service = new LibraryIndexService(_factory, state, _clock, _identities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,6 +138,11 @@ public sealed class LibraryIndexServiceTests : IAsyncLifetime
|
||||
};
|
||||
await _service.UpsertAsync(Context(_userA, "principal-a", "music"), input);
|
||||
await _service.UpsertAsync(Context(_userB, "principal-b", "music"), input);
|
||||
var rescanned = await _service.UpsertAsync(Context(_userA, "principal-a", "music"), Input() with
|
||||
{
|
||||
MusicBrainzRecordingId = "16ba7915-2acf-42b2-8c87-ed67090dca91"
|
||||
});
|
||||
Assert.Equal(canonicalId, rescanned.CanonicalRecordingId);
|
||||
|
||||
await using var verification = await _factory.CreateDbContextAsync();
|
||||
Assert.Equal(2, await verification.LibraryTracks.CountAsync());
|
||||
@@ -141,6 +152,106 @@ public sealed class LibraryIndexServiceTests : IAsyncLifetime
|
||||
alias.Namespace);
|
||||
Assert.Equal("local-item", alias.ExternalId);
|
||||
Assert.Equal(canonicalId, alias.CanonicalEntityId);
|
||||
Assert.All(await verification.LibraryTracks.ToListAsync(), track => Assert.Equal(1, track.AcceptedDecisionVersion));
|
||||
Assert.Null((await verification.CanonicalRecordings.FindAsync(canonicalId))!.MusicBrainzRecordingId);
|
||||
_catalogQueue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeAlias_RecoversAssignmentLostByLegacyRescan()
|
||||
{
|
||||
var context = Context(_userA, "principal-a", "music");
|
||||
var canonical = await _identities.CreateRecordingAsync(context.RequireActor(), "existing-native");
|
||||
var indexed = await _service.UpsertAsync(context, Input() with { CanonicalRecordingId = canonical.Recording.Id });
|
||||
await using (var db = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
var track = await db.LibraryTracks.FindAsync(indexed.Id);
|
||||
track!.CanonicalRecordingId = null;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var rescanned = await _service.UpsertAsync(context, Input());
|
||||
Assert.Equal(canonical.Recording.Id, rescanned.CanonicalRecordingId);
|
||||
await using var verification = await _factory.CreateDbContextAsync();
|
||||
Assert.Single(await verification.CanonicalRecordings.ToListAsync());
|
||||
Assert.Single(await verification.CanonicalCatalogAliases.ToListAsync());
|
||||
_catalogQueue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeRecording_EnrichesProviderIdentityAndQueuesCatalogWithoutChangingNativeMetadata()
|
||||
{
|
||||
const string mbid = "16ba7915-2acf-42b2-8c87-ed67090dca91";
|
||||
var context = Context(_userA, "principal-a", "music");
|
||||
var original = await _identities.CreateRecordingAsync(context.RequireActor(), "provider-first", Input().Isrc);
|
||||
_catalogQueue.Setup(queue => queue.EnqueueRecordingAsync(
|
||||
It.Is<ProviderActorContext>(actor => actor.TenantId == _tenantId && actor.UserId == _userA),
|
||||
mbid, context.CorrelationId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DurableJobEnqueueResult(Guid.CreateVersion7(), true));
|
||||
|
||||
var indexed = await _service.UpsertAsync(context, Input() with { MusicBrainzRecordingId = mbid });
|
||||
var rescanned = await _service.UpsertAsync(context, Input() with { MusicBrainzRecordingId = mbid });
|
||||
Assert.Equal(original.Recording.Id, indexed.CanonicalRecordingId);
|
||||
Assert.Equal(indexed.CanonicalRecordingId, rescanned.CanonicalRecordingId);
|
||||
Assert.Equal(Input().BackendItemId, indexed.BackendItemId);
|
||||
Assert.Equal(Input().Title, indexed.Title);
|
||||
Assert.Equal(Input().FilePath, indexed.FilePath);
|
||||
await using var db = await _factory.CreateDbContextAsync();
|
||||
var canonical = Assert.Single(await db.CanonicalRecordings.ToListAsync());
|
||||
Assert.Equal(mbid, canonical.MusicBrainzRecordingId);
|
||||
Assert.False(canonical.IsProvisional);
|
||||
Assert.Equal(3, await db.CanonicalCatalogAliases.CountAsync());
|
||||
Assert.All(await db.CanonicalCatalogAliases.ToListAsync(), alias => Assert.Equal(canonical.Id, alias.CanonicalEntityId));
|
||||
_catalogQueue.Verify(queue => queue.EnqueueRecordingAsync(
|
||||
It.IsAny<ProviderActorContext>(), mbid, context.CorrelationId, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeRecording_ConflictingSignalsRemainIndexedWithoutMergingOrQueuing()
|
||||
{
|
||||
var context = Context(_userA, "principal-a", "music");
|
||||
var first = await _identities.CreateRecordingAsync(context.RequireActor(), "isrc-first", Input().Isrc);
|
||||
var secondId = Guid.CreateVersion7();
|
||||
const string mbid = "16ba7915-2acf-42b2-8c87-ed67090dca91";
|
||||
await using (var db = await _factory.CreateDbContextAsync())
|
||||
{
|
||||
db.CanonicalRecordings.Add(new CanonicalRecordingRecord
|
||||
{
|
||||
Id = secondId,
|
||||
TenantId = _tenantId,
|
||||
CreatedByUserId = _userA,
|
||||
MusicBrainzRecordingId = mbid,
|
||||
CreatedAt = _clock.UtcNow,
|
||||
UpdatedAt = _clock.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var indexed = await _service.UpsertAsync(context, Input() with { MusicBrainzRecordingId = mbid });
|
||||
Assert.Null(indexed.CanonicalRecordingId);
|
||||
Assert.Equal(mbid, indexed.MusicBrainzRecordingId);
|
||||
Assert.Single(await _service.GetMatchCandidatesAsync(context, "music"));
|
||||
await using var verification = await _factory.CreateDbContextAsync();
|
||||
Assert.Equal(2, await verification.CanonicalRecordings.CountAsync());
|
||||
Assert.Null((await verification.CanonicalRecordings.FindAsync(first.Recording.Id))!.MusicBrainzRecordingId);
|
||||
var audit = await verification.AuditEvents.SingleAsync(item => item.Category == "library-index");
|
||||
Assert.Contains("\"canonicalEnrichment\":\"deferred\"", audit.DetailsJson);
|
||||
_catalogQueue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("not-a-mbid")]
|
||||
[InlineData("00000000-0000-0000-0000-000000000000")]
|
||||
public async Task NativeRecording_WithoutValidMbidStillIndexes(string? mbid)
|
||||
{
|
||||
var indexed = await _service.UpsertAsync(Context(_userA, "principal-a", "music"),
|
||||
Input() with { MusicBrainzRecordingId = mbid });
|
||||
Assert.Null(indexed.CanonicalRecordingId);
|
||||
await using var db = await _factory.CreateDbContextAsync();
|
||||
Assert.Empty(await db.CanonicalRecordings.ToListAsync());
|
||||
Assert.Single(await db.LibraryTracks.ToListAsync());
|
||||
_catalogQueue.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Matching;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Services.MusicBrainz;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
@@ -260,8 +262,10 @@ public sealed class CanonicalCatalogStorageTests : IAsyncLifetime
|
||||
Assert.Equal("fixture", facts[1].SourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrainzMashGraphIngest_IsAtomicIdempotentAndPreservesEditionIdentity()
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task BrainzMashGraphIngest_IsAtomicIdempotentAndPreservesEditionIdentity(bool enrichExisting)
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 9, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var tenantId = Guid.CreateVersion7();
|
||||
@@ -324,6 +328,17 @@ public sealed class CanonicalCatalogStorageTests : IAsyncLifetime
|
||||
var graph = new MusicBrainzCatalogGraph(release, releaseGroup, [artist]);
|
||||
var source = new MusicBrainzCatalogSource(
|
||||
"BrainzMash", "brainzmash:ws2", now, now.AddDays(7));
|
||||
Guid? originalRecordingId = null;
|
||||
if (enrichExisting)
|
||||
{
|
||||
var identities = new TrackIdentityService(new TestDbContextFactory(_database.Options),
|
||||
ReadyStorage(), Mock.Of<IPlatformClock>(item => item.UtcNow == now));
|
||||
var original = await identities.CreateRecordingAsync(actor, "provider-first", "USRC17607839");
|
||||
originalRecordingId = original.Recording.Id;
|
||||
var identified = await identities.CreateRecordingAsync(actor, "catalog-evidence", "USRC17607839",
|
||||
"55555555-5555-4555-8555-555555555555");
|
||||
Assert.Equal(originalRecordingId, identified.Recording.Id);
|
||||
}
|
||||
|
||||
var first = await service.IngestAsync(actor, graph, source);
|
||||
var repeated = await service.IngestAsync(
|
||||
@@ -333,9 +348,9 @@ public sealed class CanonicalCatalogStorageTests : IAsyncLifetime
|
||||
|
||||
Assert.Equal(first.ReleaseGroupId, repeated.ReleaseGroupId);
|
||||
Assert.Equal(first.ReleaseId, repeated.ReleaseId);
|
||||
Assert.Equal(7, first.EntitiesCreated);
|
||||
Assert.Equal(enrichExisting ? 6 : 7, first.EntitiesCreated);
|
||||
Assert.Equal(0, repeated.EntitiesCreated);
|
||||
Assert.Equal(7, first.Evidence.AliasesCreated);
|
||||
Assert.Equal(enrichExisting ? 6 : 7, first.Evidence.AliasesCreated);
|
||||
Assert.Equal(7, first.Evidence.FactsCreated);
|
||||
Assert.Equal(7, repeated.Evidence.AliasesSeen);
|
||||
Assert.Equal(0, repeated.Evidence.FactsCreated);
|
||||
@@ -348,11 +363,20 @@ public sealed class CanonicalCatalogStorageTests : IAsyncLifetime
|
||||
Assert.Single(await verification.CanonicalReleases.Where(item => item.TenantId == tenantId).ToListAsync());
|
||||
Assert.Equal(2, await verification.CanonicalRecordings.CountAsync(item => item.TenantId == tenantId));
|
||||
Assert.Equal(2, await verification.CanonicalReleaseTracks.CountAsync(item => item.TenantId == tenantId));
|
||||
Assert.Equal(7, await verification.CanonicalCatalogAliases.CountAsync(item => item.TenantId == tenantId));
|
||||
Assert.Equal(enrichExisting ? 8 : 7, await verification.CanonicalCatalogAliases.CountAsync(item => item.TenantId == tenantId));
|
||||
Assert.Equal(7, await verification.CatalogFacts.CountAsync(item => item.TenantId == tenantId));
|
||||
Assert.All(
|
||||
await verification.CanonicalRecordings.Where(item => item.TenantId == tenantId).ToListAsync(),
|
||||
item => Assert.False(item.IsProvisional));
|
||||
if (originalRecordingId.HasValue)
|
||||
{
|
||||
var preserved = await verification.CanonicalRecordings.SingleAsync(item => item.Id == originalRecordingId.Value);
|
||||
Assert.Equal("Sunroof", preserved.Title);
|
||||
Assert.Equal("USRC17607839", preserved.Isrc);
|
||||
Assert.Equal(163_000, preserved.DurationMilliseconds);
|
||||
Assert.Equal(originalRecordingId.Value, (await verification.CanonicalReleaseTracks.SingleAsync(item =>
|
||||
item.TenantId == tenantId && item.MusicBrainzTrackId == "44444444-4444-4444-8444-444444444444")).CanonicalRecordingId);
|
||||
}
|
||||
}
|
||||
|
||||
var invalidTenantId = Guid.CreateVersion7();
|
||||
|
||||
@@ -74,15 +74,18 @@ public sealed class LibraryIndexService : ILibraryIndexService
|
||||
private readonly IDbContextFactory<AllstarrDbContext> _contextFactory;
|
||||
private readonly DurableStorageState _storageState;
|
||||
private readonly IPlatformClock _clock;
|
||||
private readonly ITrackIdentityService _identities;
|
||||
|
||||
public LibraryIndexService(
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
DurableStorageState storageState,
|
||||
IPlatformClock clock)
|
||||
IPlatformClock clock,
|
||||
ITrackIdentityService identities)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_storageState = storageState;
|
||||
_clock = clock;
|
||||
_identities = identities;
|
||||
}
|
||||
|
||||
public async Task<IndexedLibraryTrack> UpsertAsync(
|
||||
@@ -141,8 +144,8 @@ public sealed class LibraryIndexService : ILibraryIndexService
|
||||
record.MusicBrainzReleaseId = NormalizeGuid(input.MusicBrainzReleaseId);
|
||||
record.MusicBrainzArtistId = NormalizeGuid(input.MusicBrainzArtistId);
|
||||
record.ProviderIdsJson = JsonSerializer.Serialize(providerIds, JsonOptions);
|
||||
record.CanonicalRecordingId = input.CanonicalRecordingId;
|
||||
record.AcceptedDecisionVersion = input.AcceptedDecisionVersion;
|
||||
record.CanonicalRecordingId = input.CanonicalRecordingId ?? record.CanonicalRecordingId;
|
||||
record.AcceptedDecisionVersion = input.AcceptedDecisionVersion ?? record.AcceptedDecisionVersion;
|
||||
record.CoverArtReference = ValidateReference(input.CoverArtReference);
|
||||
record.SourceModifiedAt = input.SourceModifiedAt;
|
||||
record.IndexedAt = now;
|
||||
@@ -152,6 +155,34 @@ public sealed class LibraryIndexService : ILibraryIndexService
|
||||
db.LibraryTracks.Add(record);
|
||||
}
|
||||
|
||||
var enrichment = "unchanged";
|
||||
if (!record.CanonicalRecordingId.HasValue)
|
||||
{
|
||||
var aliasNamespace = CanonicalCatalogKeys.NativeTrackNamespace(record.Protocol, record.BackendInstanceId);
|
||||
var itemHash = CanonicalCatalogKeys.Hash(record.BackendItemId);
|
||||
record.CanonicalRecordingId = await db.CanonicalCatalogAliases.AsNoTracking()
|
||||
.Where(alias => alias.TenantId == principal.TenantId && alias.Namespace == aliasNamespace &&
|
||||
alias.EntityKind == CanonicalCatalogEntityKind.Recording &&
|
||||
alias.ExternalIdHash == itemHash && alias.ExternalId == record.BackendItemId)
|
||||
.Select(alias => (Guid?)alias.CanonicalEntityId).SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
if (Guid.TryParse(record.MusicBrainzRecordingId, out var mbid) && mbid != Guid.Empty &&
|
||||
(!record.CanonicalRecordingId.HasValue || await db.CanonicalRecordings.AsNoTracking().AnyAsync(
|
||||
item => item.TenantId == principal.TenantId && item.Id == record.CanonicalRecordingId &&
|
||||
item.MusicBrainzRecordingId == mbid.ToString("D"), cancellationToken)))
|
||||
{
|
||||
try
|
||||
{
|
||||
var identity = await _identities.CreateRecordingAsync(
|
||||
executionContext.RequireActor(), executionContext.CorrelationId,
|
||||
record.Isrc, mbid.ToString("D"), cancellationToken);
|
||||
record.CanonicalRecordingId ??= identity.Recording.Id;
|
||||
enrichment = "linked";
|
||||
}
|
||||
catch (ArgumentException) { enrichment = "invalid-signals"; }
|
||||
catch (InvalidOperationException) { enrichment = "deferred"; }
|
||||
}
|
||||
|
||||
await CanonicalCatalogIdentityProjection.ProjectLibraryTrackAsync(
|
||||
db,
|
||||
executionContext.RequireActor(),
|
||||
@@ -173,7 +204,8 @@ public sealed class LibraryIndexService : ILibraryIndexService
|
||||
libraryTrackId = record.Id,
|
||||
libraryScopeId = input.LibraryScopeId,
|
||||
backendInstanceId = principal.BackendInstanceId,
|
||||
hasCanonicalRecording = input.CanonicalRecordingId.HasValue
|
||||
hasCanonicalRecording = record.CanonicalRecordingId.HasValue,
|
||||
canonicalEnrichment = enrichment
|
||||
}),
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ using System.Text.RegularExpressions;
|
||||
using allstarr.Core.Capabilities;
|
||||
using allstarr.Core.Operations;
|
||||
using allstarr.Core.Storage;
|
||||
using allstarr.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Core.Matching;
|
||||
|
||||
@@ -136,17 +138,20 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
private readonly DurableStorageState _storageState;
|
||||
private readonly IPlatformClock _clock;
|
||||
private readonly IMusicBrainzCatalogRefreshQueue? _catalogRefreshQueue;
|
||||
private readonly IOptions<MusicBrainzSettings>? _catalogSettings;
|
||||
|
||||
public TrackIdentityService(
|
||||
IDbContextFactory<AllstarrDbContext> contextFactory,
|
||||
DurableStorageState storageState,
|
||||
IPlatformClock clock,
|
||||
IMusicBrainzCatalogRefreshQueue? catalogRefreshQueue = null)
|
||||
IMusicBrainzCatalogRefreshQueue? catalogRefreshQueue = null,
|
||||
IOptions<MusicBrainzSettings>? catalogSettings = null)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_storageState = storageState;
|
||||
_clock = clock;
|
||||
_catalogRefreshQueue = catalogRefreshQueue;
|
||||
_catalogSettings = catalogSettings;
|
||||
}
|
||||
|
||||
public async Task<CanonicalRecordingCreationResult> CreateRecordingAsync(
|
||||
@@ -168,112 +173,81 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
var userId = actor.UserId ?? throw new UnauthorizedAccessException(
|
||||
"Creating a canonical recording requires a user actor.");
|
||||
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await ValidateActorAsync(context, actor, cancellationToken);
|
||||
var existing = await FindCanonicalByExactSignalsAsync(
|
||||
context,
|
||||
actor.TenantId,
|
||||
normalizedIsrc,
|
||||
normalizedMusicBrainzId,
|
||||
cancellationToken);
|
||||
if (existing != null)
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
EnsureSignalsCompatible(existing, normalizedIsrc, normalizedMusicBrainzId);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
context,
|
||||
actor,
|
||||
existing,
|
||||
_clock.UtcNow,
|
||||
cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
correlationId,
|
||||
"canonical-recording.create",
|
||||
"already-exists",
|
||||
new
|
||||
{
|
||||
canonicalRecordingId = existing.Id,
|
||||
hasIsrc = normalizedIsrc != null,
|
||||
hasMusicBrainzRecordingId = normalizedMusicBrainzId != null
|
||||
});
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
return await CompleteCreationAsync(
|
||||
actor, correlationId, existing, created: false, cancellationToken);
|
||||
}
|
||||
|
||||
var now = _clock.UtcNow;
|
||||
var record = new CanonicalRecordingRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedByUserId = userId,
|
||||
Isrc = normalizedIsrc,
|
||||
MusicBrainzRecordingId = normalizedMusicBrainzId,
|
||||
IsProvisional = normalizedMusicBrainzId == null,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
context.CanonicalRecordings.Add(record);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
context,
|
||||
actor,
|
||||
record,
|
||||
now,
|
||||
cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
correlationId,
|
||||
"canonical-recording.create",
|
||||
"created",
|
||||
new
|
||||
{
|
||||
canonicalRecordingId = record.Id,
|
||||
hasIsrc = normalizedIsrc != null,
|
||||
hasMusicBrainzRecordingId = normalizedMusicBrainzId != null
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
return await CompleteCreationAsync(
|
||||
actor, correlationId, record, created: true, cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
context.ChangeTracker.Clear();
|
||||
existing = await FindCanonicalByExactSignalsAsync(
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await ValidateActorAsync(context, actor, cancellationToken);
|
||||
var record = await FindCanonicalByExactSignalsAsync(
|
||||
context,
|
||||
actor.TenantId,
|
||||
normalizedIsrc,
|
||||
normalizedMusicBrainzId,
|
||||
cancellationToken);
|
||||
if (existing == null)
|
||||
var created = record == null;
|
||||
var enriched = false;
|
||||
var now = _clock.UtcNow;
|
||||
if (record == null)
|
||||
{
|
||||
throw;
|
||||
record = new CanonicalRecordingRecord
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedByUserId = userId,
|
||||
Isrc = normalizedIsrc,
|
||||
MusicBrainzRecordingId = normalizedMusicBrainzId,
|
||||
IsProvisional = normalizedMusicBrainzId == null,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
context.CanonicalRecordings.Add(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
EnsureSignalsCompatible(record, normalizedIsrc, normalizedMusicBrainzId);
|
||||
enriched = record.Isrc == null && normalizedIsrc != null ||
|
||||
record.MusicBrainzRecordingId == null && normalizedMusicBrainzId != null;
|
||||
if (enriched)
|
||||
{
|
||||
record.Isrc ??= normalizedIsrc;
|
||||
record.MusicBrainzRecordingId ??= normalizedMusicBrainzId;
|
||||
record.IsProvisional = record.MusicBrainzRecordingId == null;
|
||||
record.UpdatedAt = now;
|
||||
record.Revision++;
|
||||
}
|
||||
}
|
||||
|
||||
EnsureSignalsCompatible(existing, normalizedIsrc, normalizedMusicBrainzId);
|
||||
await CanonicalCatalogIdentityProjection.ProjectRecordingSignalsAsync(
|
||||
context,
|
||||
actor,
|
||||
existing,
|
||||
_clock.UtcNow,
|
||||
cancellationToken);
|
||||
context, actor, record, now, cancellationToken);
|
||||
AddAudit(
|
||||
context,
|
||||
actor,
|
||||
correlationId,
|
||||
context, actor, correlationId,
|
||||
"canonical-recording.create",
|
||||
"concurrent-existing",
|
||||
new { canonicalRecordingId = existing.Id });
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
created ? "created" : enriched ? "enriched" : "already-exists",
|
||||
new
|
||||
{
|
||||
canonicalRecordingId = record.Id,
|
||||
hasIsrc = normalizedIsrc != null,
|
||||
hasMusicBrainzRecordingId = normalizedMusicBrainzId != null
|
||||
});
|
||||
try
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException error) when (attempt < 2 && IsConcurrentRecordingWrite(error))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return await CompleteCreationAsync(
|
||||
actor, correlationId, existing, created: false, cancellationToken);
|
||||
actor, correlationId, record, created, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsConcurrentRecordingWrite(DbUpdateException error) =>
|
||||
error is DbUpdateConcurrencyException || error.InnerException is Npgsql.PostgresException
|
||||
{
|
||||
SqlState: Npgsql.PostgresErrorCodes.UniqueViolation
|
||||
} postgres && (postgres.ConstraintName is "IX_canonical_recordings_TenantId_Isrc" or
|
||||
"IX_canonical_recordings_TenantId_MusicBrainzRecordingId" ||
|
||||
postgres.ConstraintName?.StartsWith("IX_canonical_catalog_aliases_", StringComparison.Ordinal) == true);
|
||||
|
||||
private async Task<CanonicalRecordingCreationResult> CompleteCreationAsync(
|
||||
ProviderActorContext actor,
|
||||
string correlationId,
|
||||
@@ -281,7 +255,8 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
bool created,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_catalogRefreshQueue != null && recording.MusicBrainzRecordingId is { } mbid)
|
||||
if (_catalogRefreshQueue != null && _catalogSettings?.Value.Enabled != false &&
|
||||
recording.MusicBrainzRecordingId is { } mbid)
|
||||
{
|
||||
await _catalogRefreshQueue.EnqueueRecordingAsync(
|
||||
actor, mbid, correlationId, cancellationToken);
|
||||
@@ -759,7 +734,7 @@ public sealed class TrackIdentityService : ITrackIdentityService
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidates = await context.CanonicalRecordings.AsNoTracking()
|
||||
var candidates = await context.CanonicalRecordings
|
||||
.Where(item => item.TenantId == tenantId &&
|
||||
((isrc != null && item.Isrc == isrc) ||
|
||||
(musicBrainzRecordingId != null &&
|
||||
|
||||
@@ -6,8 +6,6 @@ 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.
|
||||
@@ -30,7 +28,6 @@ User and operator guides describe shipped behavior. Contributor assessments expl
|
||||
- [WebUI design system](../DESIGN.md)
|
||||
- [Test and qualification tools](../tools/tests/README.md)
|
||||
- [Provider capability module](../allstarr/Core/Capabilities/README.md)
|
||||
- [Release readiness assessment](release-readiness.md): dated feature inventory, code-churn evidence, and proposed first-release gates; not a shipped-support contract.
|
||||
|
||||
## Documentation rules
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# 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.
|
||||
@@ -81,7 +81,7 @@ Allstarr changes a native response only when a documented feature requires it: e
|
||||
|
||||
`TrackIdentityService`, backend library indexing, persisted provider routes, and the playlist orchestration layer are the shared path. Accepted decisions are reusable by automatic matching, interactive matching, synchronization, playback, and event projections. Candidates that satisfy confidence and artist-evidence requirements are selected by local-first/configured streaming priority, not relative confidence windows. Only tentative selection retains preference windows. Cached provider reuse passes through the same decision engine with current local candidates and rejections; it does not force acceptance or overwrite confidence. Matching-algorithm changes enqueue owner-scoped `track-match.rematch-all` jobs that replace stale automatic decisions in bounded batches while preserving manual authority and append-only history. Playlist refresh and materialization run through durable playlist links and the `playlist.materialize` job; there is no provider-specific matching coordinator. A one-time import reuses its first published source snapshot and no longer requires the source account for later projection or rebuilds. Keep-all retention fans resolved external routes into idempotent `playlist.retain-track` jobs, reauthorizes download accounts for the exact owner and library at execution time, and publishes verified files through the managed-file owner.
|
||||
|
||||
Authenticated search keeps successful tracks, albums, and artists when another provider or search category fails. A batched, account-aware identity lookup collapses external track hits only when accepted links identify the same recording; tentative, released, replaced, pinned, and unknown links remain separate. The representative follows configured streaming order. This is still a provider-shaped search result, not the stable canonical protocol ID or native-item merge required by the [unified music service plan](unified-music-service-plan.md).
|
||||
Authenticated search keeps successful tracks, albums, and artists when another provider or search category fails. A batched, account-aware identity lookup collapses external track hits only when accepted links identify the same recording; tentative, released, replaced, pinned, and unknown links remain separate. The representative follows configured streaming order. This is still a provider-shaped search result, not the stable canonical protocol ID or native-item merge needed for unified music results.
|
||||
|
||||
## Canonical catalog ingestion
|
||||
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
# 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-27; the catalog and alias projection changes are deployed on the testing server:
|
||||
|
||||
- **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.
|
||||
- **Snapshot scope:** Snapshot capture validates a supplied provider identity against the tenant, source provider, track hash, catalog, and resolved account. Repeated captures must preserve the identity link and backend principal. PostgreSQL regressions cover foreign identities, both permitted identity scopes, and immutable retries.
|
||||
- **Source reconciliation:** Automatic rematching now moves a provisional source identity and its catalog alias together when it reuses an existing provider recording. A source anchored by an ISRC, MusicBrainz ID, confirmed recording, or manual identity cannot be reassigned by this path. The operation preserves old recordings and historical decisions, records an audit event, and retries concurrent identity writes. A forward migration repairs unanchored stale aliases, removes redundant aliases with identical targets, and normalizes legacy hashes; conflicting targets remain untouched. Isolated PostgreSQL regressions cover converging sources, repeated rematches, protected evidence, and migration replay. Generic evidence ingestion still rejects arbitrary alias reassignment. This is not a general-purpose recording merge.
|
||||
- **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
|
||||
|
||||
Current increment: authenticated search isolates provider/category failures and collapses external results with the same accepted, account-authorized recording link. It retains existing provider IDs for client compatibility and leaves unknown, tentative, and pinned results separate. This does not satisfy the Stage 3 exit condition: stable canonical protocol IDs, native representative merging, and coherent artist/release browse are still pending. Do not describe the unified catalog as shipped or remove legacy aliases on the strength of this increment.
|
||||
|
||||
- Query canonical projections and overlay user-authorized route availability.
|
||||
- Return stable Allstarr IDs and resolve legacy aliases.
|
||||
- Implement coherent artist, release, track, and discography browse.
|
||||
- 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.
|
||||
|
||||
Live checkpoint on 2026-09-27: `6fdd20bb` passed 173 smoke checks against
|
||||
Jellyfin 12.1.0 with zero failures using a listener test account. The contract
|
||||
fixtures remain pinned to 12.0.0; this run does not qualify every new 12.1
|
||||
operation. The run covered dashboard and
|
||||
protocol login, native object parity, external search and browse, artwork,
|
||||
full-song decoding, exact native audio bytes, and cached external prefix/suffix
|
||||
ranges. Six checks remained blocked: three initial external range checks,
|
||||
injected playlists absent from that account, playlist writes, and other
|
||||
state-restoration tests. Deezer and the configured YouTube Music extension
|
||||
served audio; this does not qualify every provider, account tier, or client.
|
||||
The three-sample native stream run measured 34.7 ms mean Allstarr first-byte
|
||||
latency against 20.4 ms direct. OIDC remains disabled pending operator setup;
|
||||
its real identity-provider flow is not live-qualified.
|
||||
|
||||
## 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.
|
||||
@@ -1,403 +0,0 @@
|
||||
# Allstarr: feature inventory and first-release plan
|
||||
|
||||
Assessment date: 2026-09-12. Reference commit: `adf3c585a3b97b025dfa65f99a33b4703c543183`.
|
||||
|
||||
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.
|
||||
|
||||
The first release should make five promises:
|
||||
|
||||
1. Existing local music remains easy to find and play, including when an optional provider is unavailable.
|
||||
2. External search and playback complement the local library with accurate recording identity and clear availability.
|
||||
3. A user can keep an external song, then see whether it is downloaded, awaiting indexing, or available locally.
|
||||
4. Playlists prefer qualified local recordings and retain their source order and user decisions.
|
||||
5. Accounts, retained files, and background work respect user/library ownership and survive ordinary restarts and recovery.
|
||||
|
||||
Successful acquisition should have this observable lifecycle:
|
||||
|
||||
```text
|
||||
External recording
|
||||
→ explicit keep request
|
||||
→ authorized durable download
|
||||
→ verified, tagged file in the configured managed root
|
||||
→ backend scan requested
|
||||
→ native backend item observed and identity reconciled
|
||||
→ subsequent playback uses the local library
|
||||
```
|
||||
|
||||
The final steps are part of the product outcome. A successful file copy or accepted scan request does not establish that the recording is in the library.
|
||||
|
||||
Local-first must preserve recording correctness. The current matcher gives local candidates a seven-point preference window, with external provider windows of five, three, and one point; it does not make every local candidate win. Preserve manual pins/rejections and the raw acceptance threshold. For release, explicitly verify that an accepted local identity is reused and that acquisition can transition an external identity to that native item. Do not silently change the established matching policy during cleanup. See [`TrackMatchDecisionEngine`](../allstarr/Core/Matching/TrackMatchDecisionEngine.cs).
|
||||
|
||||
## First-release scope decision (2026-09-13)
|
||||
|
||||
**Maintainer decision: do not release the Intelligence workspace with the core application.** First-release navigation is Home, Library, Integrations, Activity, and Settings. Mobile exposes Home, Library, Activity, and More; More owns Integrations and Settings. Personal provider-playlist import stays in Library: it is different from importing listening history into Intelligence.
|
||||
|
||||
- **Implemented now:** remove Intelligence/Insights from the shared desktop, mobile, and More navigation. Mobile tracks size themselves from their contents instead of assuming five buttons. Keep the existing visual system and both themes.
|
||||
- **Preserved now:** the direct `#/intelligence` route, its tests, existing accounts, history, data controls, and opt-in background jobs. This is a reversible navigation change, not a feature shutdown or data migration. The route is still bundled and the AudioMuse configuration link still reaches it; navigation hiding is neither authorization nor complete release exclusion.
|
||||
- **Required before publication:** omit the deferred workspace from the release route/bundle, provide an explanatory destination for old links, and remove or label its remaining entry points. Inspect Services/AudioMuse, Home listening aggregates, configuration, onboarding, and public capability copy together. Retain core now-playing and independent scrobble delivery; do not accidentally disable playback observation needed by those features. Use one explicit release boundary, not scattered component flags.
|
||||
- **Existing-user safety:** inventory scheduled recommendations, import jobs, listening-app keys, and consent/data-management dependencies before changing runtime registration. Specify how existing users export/manage their data or continue using an explicit development build. Do not purge history, revoke credentials, or silently change opt-in collection because a tab disappeared.
|
||||
- **Separate later milestone:** qualify direct-listen history, imports, recommendations, AudioMuse, feedback, ephemeral playlists, and automation together. These are not blockers for the core release once safely isolated, and are not core-release marketing promises.
|
||||
|
||||
## Canonical recordings and multi-source routing (proposed)
|
||||
|
||||
Evolve the existing [identity service](../allstarr/Core/Matching/TrackIdentityService.cs), [router](../allstarr/Core/Routing/ProviderRouter.cs), and [protocol gateway](../allstarr/Core/Protocols/ProtocolProviderGateway.cs); do not build a second routing provider. They already separate provider identities, scoped capability/account eligibility, and stream leases. The missing product contract is **one recording with several verified playable sources**, rather than a provider-branded song that loses its alternatives when one candidate wins.
|
||||
|
||||
| Responsibility | Planned behavior | Acceptance condition |
|
||||
| --- | --- | --- |
|
||||
| Matching | Persist qualified alternatives and the evidence tying each to the recording. Keep manual authority and rejected candidates explicit. | An unavailable winner does not require rediscovering every source; a rejected or different-version recording cannot reappear through fallback. |
|
||||
| Selection | Prefer the verified local source, then use the established 7/5/3/1-point preference policy among qualifying candidates. Evaluate against the strongest eligible confidence, not chained pairwise wins. | Preference selects an ordering; it never inflates raw confidence to cross acceptance gates. Manual pins only permit fallback according to an explicit policy. |
|
||||
| Playback | Try authorized ready alternatives within one startup deadline. Translate identity before playback when possible; avoid serial catalog searches after pressing Play. Recheck account eligibility when serving. | Lease failure **and** failure opening the upstream HTTP response before commitment can advance to the next candidate. Expired access, ranges, cancellation, rate limits, and exhausted alternatives have deterministic outcomes. |
|
||||
| Media continuity | Pin the opened media representation for the request/session's range contract. | No silent mid-stream splicing between different encodings, byte lengths, or recordings; a representation change needs a valid new playback attempt. |
|
||||
| Presentation | One external recording entry with a stable Allstarr identity and inspectable source alternatives/current source. Preserve original native objects and IDs. | Provider failover does not duplicate playlist entries or invalidate saved references. Migrate existing provider-shaped IDs through aliases before changing emitted IDs. |
|
||||
| Local handoff | After explicit acquisition and observed backend indexing, attach the native identity and prefer it for subsequent playback. | The track remains playable with external providers unavailable, without losing playlist membership, artwork, or manual decisions. |
|
||||
|
||||
Provider names can leave the main song title, but must remain available in details, diagnostics, and account decisions. Allstarr-injected songs use `[A]`; explicit injected songs use `[A] [E]`. Local backend titles and objects must remain unchanged.
|
||||
|
||||
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) 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.
|
||||
|
||||
**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
|
||||
|
||||
“Present” below means implementation was found. It does not imply an end-to-end release qualification. “Working-tree addition” identifies significant functionality that depends on uncommitted code at the assessment baseline.
|
||||
|
||||
| Feature | Current implementation and limits | First-release disposition |
|
||||
| --- | --- | --- |
|
||||
| Native Jellyfin gateway | Authentication, browse/search, items, artwork, audio/ranges, playlists, favorites, sessions, WebSocket relay, lyrics, and music-specific route policy. Versioned protocol fixtures exist. | Essential. Qualify exact backend/client versions and native parity. |
|
||||
| Native Subsonic/OpenSubsonic gateway | Query/form authentication, XML/JSON, search, items, audio, art, lyrics, playlists, stars, and playback observations. One deployment selects one protocol. | Retain. Require equivalent live qualification before advertising equal support. |
|
||||
| Local plus external search | Typed metadata gateway, backend response adapters, provider ordering, and external identities are present. Spotify is not a general searchable audio catalog adapter. | Essential. Preserve local metadata and distinguish playable results from source-only rows. |
|
||||
| External playback | Typed leases for Deezer, Qobuz, Apple gateway, and eligible extensions; range behavior varies by provider. Controllers still have a legacy download-and-stream fallback. | Essential for selected qualified providers. Consolidate ownership and measure startup. |
|
||||
| Playback cache and quality | Progressive cache publication, lower-quality requests, media metadata, and cleanup exist. Cache lookup and legacy storage policy do not use the same ownership model as managed acquisitions. | Keep; fix scope and consistent cache/keep semantics. |
|
||||
| Explicit cache retention | Cached/Kept views, promote, download/export, delete, and reference checks exist. Promotion moves files directly in the controller. Kept root selection differs from managed placement. | Essential; route through the managed-file owner and correct the projection. |
|
||||
| Favorite-triggered acquisition | Configurable durable actions: match, download, place, enrich, refresh, and scrobble. Acquisition actions are off by default. A favorite is not inherently a download. | Keep a simple opt-in policy. Explain its result independently from the favorite itself. |
|
||||
| Safe managed file placement | Scoped ownership, checksums, references, path validation, staging/recovery journal, copy/link operations, and explicit removal exist. | Essential infrastructure. Reuse it across every retention entry point. |
|
||||
| Backend refresh and indexing | Authenticated refresh adapters and a separate catalog indexing service exist. Refresh job success means the scan request was accepted. | Essential. Join acquisition to scan completion, native item discovery, and route reconciliation. |
|
||||
| Personal provider accounts | Encrypted account secrets, exact scope, audiences, backend identity mapping, and account-aware routing exist. Apple gateway login is deployment-wide; MusicKit personal-library accounts are separate. | Essential. Prove cache-hit and background-job isolation, not just secret storage. |
|
||||
| Playlist discovery/import | Spotify personal playlists, Apple MusicKit library playlists, and provider catalog playlist reads. Snapshots, owner scope, artwork, and source updates exist. | Keep. Start with the providers actually qualified through the full journey. |
|
||||
| One-time versus linked imports | Frozen snapshots and manual/scheduled refresh behavior exist; current working-tree changes strengthen reuse after account disablement. | Keep two clear choices: import once or keep linked. Test that one-time imports stop reading the source. |
|
||||
| Keep every playlist song | Working-tree addition: `playlist.retain-track` jobs download and place resolved external tracks. Local/unresolved rows are skipped by the queue. No subsequent backend refresh is requested in this handler. | Core to library growth, but incomplete as an acquisition-to-local workflow. |
|
||||
| Playlist projections | Virtual, materialized, hybrid; source/resolved/target views; reconcile/recreate; stale-entry and manual-entry policies. Backend materialization is limited to eligible local/native entries. | Keep simple defaults. Put advanced modes behind an explicit advanced choice; test the retained combinations. |
|
||||
| Match review and authority | Canonical/provider identities, evidence scoring, accepted/tentative/ambiguous states, pins, rejections, manual provider routes, and history. | Essential. Preserve authority across all projections and retries. |
|
||||
| Bulk/versioned rematching | Working-tree addition: gradual batches of 25, algorithm-version rollout, manual-authority guards, append-only history. | Keep after migration/restart and concurrency tests pass. Avoid deleting manual decisions during cleanup. |
|
||||
| Lyrics and managed tags | Backend lyrics, LRCLib, optional Spotify and Apple lyrics; safe managed-file enrichment using provider/MusicBrainz facts. | Supporting feature. Failures must not hold up audio or acquiring the file. |
|
||||
| Listening history and scrobbling | Scoped playback signals, durable delivery/checkpoints, opt-in history, live views, and Last.fm/ListenBrainz targets. | Retain core now-playing/scrobble delivery if qualified; defer the Intelligence history workspace. Preserve existing consent and data controls. |
|
||||
| History imports | Spotify Extended History, Last.fm, ListenBrainz, Koito, and Maloja formats; preview, jobs, dedupe, retention, corrections, export/purge. | Deferred with Intelligence, not personal playlist imports. Preserve existing import data and recovery. |
|
||||
| Recommendations | Local rules, Jellyfin InstantMix, MusicBrainz relationships, Last.fm, several ListenBrainz feeds, AudioMuse, feedback, generated sets, and schedules. | Deferred with Intelligence. Existing runtime work must be inventoried before release isolation; hiding navigation does not stop schedules. |
|
||||
| AudioMuse workbench | Analysis, similarity, search, paths, blends, fingerprinting, clusters, maps, and generated sets have explicit surfaces. | Deferred with Intelligence, including its setup entry points. Not a core-release prerequisite. |
|
||||
| Provider extension platform | Registry/package verification, permissions, account boundaries, staging, activation, rollback, JS runtime, hooks, and UI. No bundled third-party registry/packages. | Defer public SDK/store expansion. Keep any retained extension path permissioned. |
|
||||
| Operational dashboard | Home, activity, durable jobs, health/connectivity, match review, cache diagnostics, settings, onboarding. | Keep the controls needed to connect, play, keep, and recover. Freeze cosmetic redesign. |
|
||||
| Install/update/backup/restore | Compose, source/image modes, optional profiles, PostgreSQL/key-ring backups, full state transfer, restore, and separate media retention. | Essential. Fresh install and tested restore are release gates. |
|
||||
| Selective state transfer | Category selection, dependencies, merge/replace, conflict validation, preview, and UI. | Advanced maintenance. Preserve full backup/restore; defer selective transfer as a selling point. |
|
||||
| Legacy configuration and M3U pathways | Old Spotify configuration endpoints coexist with durable playlist links. A registered M3U sync service has no production callers found. | Explicit deprecation/deletion candidates; do not build new behavior on them. |
|
||||
|
||||
Primary owners: [architecture](architecture/overview.md), [protocol matrix](../allstarr.Tests/Fixtures/Protocols/protocol-support-matrix.json), [protocol gateway](../allstarr/Core/Protocols/ProtocolProviderGateway.cs), [playlist orchestration](../allstarr/Core/Playlists/PlaylistOrchestrationService.cs), [favorite actions](../allstarr/Core/Favorites/FavoriteActionPipeline.cs), [playback signals](../allstarr/Core/Playback/PlaybackSignalPipeline.cs), and [recommendation registration](../allstarr/Core/Intelligence/RecommendationSourceRegistration.cs).
|
||||
|
||||
### Provider capabilities are different products
|
||||
|
||||
| Source | What Allstarr actually uses it for | Qualification boundary |
|
||||
| --- | --- | --- |
|
||||
| Jellyfin / Subsonic backend | Owned library, native playback, native playlists, favorites, and reporting | First-class local source; qualify each supported protocol/version. |
|
||||
| Deezer | Public metadata/playlist reads; account-bound streaming and downloading | Test metadata separately from authenticated audio and acquisition. |
|
||||
| Qobuz | Metadata/playlist reads; account-bound streaming and downloading | Validate upstream ranges, token expiry, quality, and complete artifacts. |
|
||||
| Apple gateway/GAMDL | Catalog metadata, single-track stream/download, lyrics, operator login | Optional wrapper/gateway dependency; a logged-in health check does not establish playback latency. |
|
||||
| Apple MusicKit | Per-user personal-library playlists and metadata | Separate credentials/capability from Apple gateway audio. Do not imply this login controls GAMDL. |
|
||||
| Spotify | Personal playlist discovery/import and optional lyrics | No generic Spotify audio streaming/download capability is advertised by the support catalog. Connection currently uses account cookies/token exchange, not a conventional end-user OAuth onboarding flow. |
|
||||
| LRCLib | Lyrics | Optional public fallback; should not block playback. |
|
||||
| MusicBrainz | Identity/tag enrichment and local recommendation evidence | Not an audio source or generic search provider here. |
|
||||
| Last.fm / ListenBrainz | Scrobbling, history-related features, and recommendations | Optional, scoped external delivery; user data controls remain necessary. |
|
||||
| AudioMuse | Optional self-hosted analysis/recommendation capability | Advanced, separately connected source; not an extension package. |
|
||||
| Extensions | Only the capabilities implemented and authorized by a verified installed package | An SDK capability is not proof of an available, tested provider. |
|
||||
|
||||
Evidence: [current support catalog](../allstarr/Services/Common/CurrentProviderSupportCatalog.cs), [Spotify adapter](../allstarr/Core/Providers/Spotify/SpotifyPlaylistCapabilityAdapter.cs), [Spotify token exchange](../allstarr/Core/Providers/Spotify/SpotifyWebTokenExchange.cs), [AudioMuse registration](../allstarr/Core/Providers/AudioMuse/AudioMuseCapabilityRegistration.cs), and [Apple gateway contract](../sidecars/apple-gateway/README.md). The generic GAMDL URL-job interface includes broader media kinds than Allstarr's managed single-song capability; those should not become additional first-release promises.
|
||||
|
||||
## Size and churn baseline
|
||||
|
||||
Counts were taken before adding this assessment. These are physical lines, including whitespace and comments, not executable statement counts. They measure maintenance surface, not quality or waste.
|
||||
|
||||
| Measure | Observed value |
|
||||
| --- | ---: |
|
||||
| Tracked repository files | 913 |
|
||||
| Changed tracked files relative to HEAD | 196: 192 modified, 4 deleted |
|
||||
| Untracked files | 15, containing 3,974 lines |
|
||||
| Tracked working diff | +6,626 / −6,398 lines |
|
||||
| Same tracked diff ignoring whitespace | +6,540 / −6,312 lines |
|
||||
| Current backend C# excluding migrations/generated EF | 110,688 lines across 402 files |
|
||||
| Current WebUI production TS/Svelte/CSS | 22,056 lines across 79 files |
|
||||
| Apple gateway production Python | 1,130 lines across 9 files |
|
||||
| Combined production source, same scope | 133,874 lines; committed HEAD has 133,092 |
|
||||
| .NET test source, excluding fixtures | 66,736 lines across 233 files |
|
||||
| WebUI browser suite | 4,647 lines in `parity.e2e.ts` |
|
||||
| EF generated designers/snapshot | 100,938 lines across 29 files |
|
||||
| Other migration C# | 5,873 lines across 55 files |
|
||||
| Commits since 2026-06-14 | 908: 641 in July, 259 in August, 8 in September |
|
||||
| Production-source churn in that window | +141,905 / −56,780 lines, or 198,685 edited lines |
|
||||
|
||||
The working tree has **782 more production lines than HEAD**, despite substantial tracked deletions. That does not make the changes wrong; it means this batch cannot be described as a net code reduction. Including test/doc changes and untracked files, the whole working difference is +4,202 physical lines.
|
||||
|
||||
Churn uses `git log --since=2026-06-14 --numstat --no-renames`, restricted to C# under `allstarr/`, production TS/Svelte/CSS under `webui/src/`, and Python under `sidecars/apple-gateway/apple_gateway/`. It excludes migrations, EF generated files, and frontend unit tests. Additions, deletions, and moves can inflate churn; commits are not weighted by size. The unrestricted repository total is +579,896 / −269,451, but large API specification changes, generated migrations, and retired trees make that unsuitable as a waste estimate.
|
||||
|
||||
### Current hotspots
|
||||
|
||||
| File | Current lines | Commits touching it in the window | Interpretation |
|
||||
| --- | ---: | ---: | --- |
|
||||
| [`webui/src/app.css`](../webui/src/app.css) | 7,557 | 111 | Roughly 34% of WebUI production source in a global stylesheet; scattered repeated breakpoint blocks make changes hard to contain. |
|
||||
| [`webui/src/lib/api.ts`](../webui/src/lib/api.ts) | 1,972 | 59 | Shared types and every feature's requests accumulate here. Much is necessary contract code; moving it alone saves nothing. |
|
||||
| [`PlaylistController.cs`](../allstarr/Controllers/PlaylistController.cs) | 730 | 58 | Old settings-backed playlist entry points coexist with the newer link API. |
|
||||
| [`AdminUiController.cs`](../allstarr/Controllers/AdminUiController.cs) | 1,543 | 49 | Configuration schema, support descriptions, dashboard projections, and activity shaping change together. |
|
||||
| [`PlaylistLinksController.cs`](../allstarr/Controllers/PlaylistLinksController.cs) | 1,623 | 40 | Many product modes, commands, credentials, schedules, and projections share one HTTP surface. |
|
||||
| [`PlaylistOrchestrationService.cs`](../allstarr/Core/Playlists/PlaylistOrchestrationService.cs) | 1,555 | 37 | Essential owner with substantial mode/state complexity; consolidate callers here rather than replace it. |
|
||||
| [`ProtocolProviderGateway.cs`](../allstarr/Core/Protocols/ProtocolProviderGateway.cs) | 1,291 | 31 | Existing shared provider route owner; finish adoption instead of adding another router. |
|
||||
| [`TrackMatchCommandService.cs`](../allstarr/Core/Matching/TrackMatchCommandService.cs) | 2,585 | — | Persistence, authority, review projection, and queries share a large owner. Contains bounded bulk reads that still need scale qualification. |
|
||||
| [`ExtensionManager.cs`](../allstarr/Services/Common/ExtensionManager.cs) | 1,869 | 32 | Substantial JS execution/host bridge; extra support surface beyond the initial product loop. |
|
||||
| [`parity.e2e.ts`](../webui/tests/parity.e2e.ts) | 4,647 | 174 | Valuable behavior coverage, but broad mocked fixtures and one large suite are a coordination hotspot. |
|
||||
|
||||
Some churn was productive: PostgreSQL consolidation, typed providers, native response preservation, and the move to Svelte removed older implementations. For example, the former `allstarr/wwwroot/js/webui.js` was touched 157 times and is no longer present. Do not count that already-removed code as a future saving.
|
||||
|
||||
Reproduce the scope with `git status --short`, `git diff HEAD --numstat`, `git ls-files --cached --others --exclude-standard`, and the filtered history command above. Count existing files only, deduplicate the path list, and include untracked source. Compare the same extensions/directories at HEAD and in the working tree. Report generated code and tests separately.
|
||||
|
||||
## Findings that should drive the release work
|
||||
|
||||
### R1 — The release candidate is not yet a reproducible source snapshot
|
||||
|
||||
**Confirmed.** The dirty tree contains 196 tracked changes and 15 untracked files. `ManagedTrackDownloadService`, playlist retention, versioned bulk rematching, and associated tests/UI include untracked dependencies. The last committed CI result cannot certify those changes.
|
||||
|
||||
**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 ownership and authorization
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
||||
**Confirmed.** [Compose](../docker-compose.yml) supplies `Library__KeptPath=/app/kept`. [`ProviderDownloadArtifactRegistration`](../allstarr/Core/Downloads/ProviderDownloadArtifactRegistration.cs) uses it for managed placement. [`DownloadsController.ResolveListRoots/Root`](../allstarr/Controllers/DownloadsController.cs) instead scans `DownloadPath/permanent` and `DownloadPath/kept`; it does not read the configured KeptPath. Managed-only files also do not satisfy that controller's `DownloadedSongMappings` requirement for removal/promotion.
|
||||
|
||||
The same controller's `PromoteCachedDownload` moves audio and its sidecar before updating the database, outside [`FilePlacementService`](../allstarr/Core/ManagedFiles/FilePlacementService.cs). A move/database or sidecar failure can therefore leave different intermediate state from the journaled managed path.
|
||||
|
||||
**Action:** one root definition, one scoped retained-file projection, and one recoverable placement/removal owner. Account for existing `permanent` and legacy `kept` files explicitly. Cover custom roots, managed-only records, sidecars, interrupted promotion, references, and original-library protection. Avoid moving users' existing media merely to simplify naming.
|
||||
|
||||
### R4 — Acquisition does not finish at a verified local item
|
||||
|
||||
**Confirmed missing orchestration in the keep-all handler.** [`PlaylistTrackRetentionJobHandler`](../allstarr/Core/Playlists/PlaylistTrackRetention.cs) ends after `MarkPlacedAsync` and its completion event. It does not request a scan, await indexing, or reconcile to a backend ID. [`FavoriteRefreshActionExecutor`](../allstarr/Core/Favorites/FavoriteRefreshActionExecutor.cs) can separately enqueue a scan, but [`BackendLibraryRefreshJobHandler`](../allstarr/Core/Enrichment/BackendLibraryRefresh.cs) marks success when the backend accepts the request.
|
||||
|
||||
The legacy downloader additionally calls `LocalLibraryService.TriggerLibraryScanAsync`: an unauthenticated Subsonic request, independent of the authenticated refresh owner. The Jellyfin refresh adapter still sets `X-Emby-Token` directly while modern proxy paths use `Authorization`; background scan authentication needs the same version qualification as interactive playback. This audit did not establish whether a particular live server accepts that header.
|
||||
|
||||
**Action:** extend the existing durable acquisition/refresh/indexing owners to complete the lifecycle. Coalesce scans per backend/library, retry observation with a bounded deadline, and show a useful pending/error state. Verify required tags and the mount visible to the backend, then persist the native identity and refresh affected playlist routes. Core regression: keep a song, index it, disable its external provider, then play the native item through Allstarr. Test the previously saved virtual/source reference too: it should resolve consistently to the accepted local item while preserving any authoritative user choice.
|
||||
|
||||
### R5 — Two playback/download implementations remain active
|
||||
|
||||
**Confirmed.** [`Program.cs`](../allstarr/Program.cs) registers the typed gateway and `IDownloadService`/concrete services. Both protocol controllers fall back to [`MultiProviderDownloadService`](../allstarr/Services/Common/MultiProviderDownloadService.cs). [`BaseDownloadService`](../allstarr/Services/Common/BaseDownloadService.cs) maintains its own active-download dictionaries, concurrency, complete-file preparation, album fan-out, and detached `Task.Run` work. This differs from the durable download/artifact path.
|
||||
|
||||
The legacy methods remain reachable fallbacks; they are not safe to delete wholesale. Concrete adapters may still supply useful provider-specific implementation. [`ManagedTrackCacheService`](../allstarr/Services/Common/ManagedTrackCacheService.cs) is another lifecycle participant and caches only in Cache mode; successful typed playback in Permanent mode does not itself publish a retained managed artifact.
|
||||
|
||||
**Action:** make playback, temporary caching, and explicit durable acquisition distinct operations behind the existing typed owners. Migrate reachable fallback callers, preserve byte/range/quality behavior, and then remove redundant orchestration. A dropped playback request need not become a durable job, but an explicitly requested acquisition must have durable ownership. Eliminate implicit whole-album fan-out from ordinary song playback unless deliberately retained as a documented option.
|
||||
|
||||
### R6 — Some code is maintained without a production entry point
|
||||
|
||||
**Strong deletion candidate from repository reference search.** [`PlaylistSyncService`](../allstarr/Services/Subsonic/PlaylistSyncService.cs) is 268 lines of provider download and M3U generation. References found are its registration, its own implementation, test setup, and ownership tests; no production call to `DownloadFullPlaylistAsync` or `AddTrackToM3UAsync` was found. A new working-tree test verifies its constructor can create a directory, not a user journey. `LocalLibraryService.GetDownloadDirectory` and `GetScanStatusAsync` similarly have no production callers found.
|
||||
|
||||
**Action:** confirm no dynamic consumer, then remove the orphan service/registration and obsolete tests rather than polishing it. Keep protocol playlist support and the live durable playlist implementation. This is a concrete small reduction opportunity; it is not a justification for deleting all Subsonic code.
|
||||
|
||||
### R7 — Playlist configuration and capability descriptions have multiple authorities
|
||||
|
||||
**Confirmed coexistence.** [`PlaylistController`](../allstarr/Controllers/PlaylistController.cs) still exposes Spotify-specific mutations to the durable `SpotifyImport:Playlists` setting and mutates runtime options. The current frontend's playlist actions use `/api/admin/playlist-links` and owner-scoped snapshots/jobs. Their coexistence creates two places to explain and maintain playlist behavior, even though the old endpoints' present use is unknown.
|
||||
|
||||
Similarly, [`CurrentProviderSupportCatalog`](../allstarr/Services/Common/CurrentProviderSupportCatalog.cs) manually describes support separately from typed registrations and runtime health. Its list omits AudioMuse even though AudioMuse is registered. Catalog tests assert the manually written declarations; they do not establish all runtime capabilities work.
|
||||
|
||||
**Action:** audit consumers, retire or translate the legacy playlist mutations through the canonical link owner, and remove the redundant runtime configuration after a documented migration/deprecation boundary. Derive capability availability from registrations plus health; keep human qualifications as annotations on those IDs. Do not remove the provider-specific HTTP adapters needed to implement those capabilities.
|
||||
|
||||
### R8 — Release verification is extensive but disconnected from some product claims
|
||||
|
||||
**Confirmed gaps, alongside useful existing coverage.** The .NET suite covers protocol fixtures, routing, persistence, file safety, and durable work. However:
|
||||
|
||||
- [`parity.e2e.ts`](../webui/tests/parity.e2e.ts) extensively intercepts `/api/admin/**`. It verifies browser behavior against fixtures, not a full dashboard → API → PostgreSQL → backend journey.
|
||||
- [`playwright.config.ts`](../webui/playwright.config.ts) has viewport coverage but no explicit WebKit/Firefox projects; [CI](../.github/workflows/ci.yml) installs Chromium only. A narrow Chromium viewport does not establish mobile Safari compatibility.
|
||||
- [`docker.yml`](../.github/workflows/docker.yml) independently builds/tests on tags/dispatch but omits Playwright. CI's browser gate is not an explicit dependency of image publication. Its container smoke establishes readiness, not authentication, media playback, or acquisition.
|
||||
- [`tools/tests`](../tools/tests/README.md) has a substantial live Jellyfin kit; no equivalent checked-in live Subsonic journey runner was found.
|
||||
- OpenAPI operation classification verifies policy/fixture coverage, not that every allowed operation works in every listed client. The client guide lists successful use without a versioned result for each promise.
|
||||
|
||||
**Action:** extend the existing test kit with shared scenarios/data and protocol adapters, connect a small real-API browser lane to disposable PostgreSQL/backends, and require the same candidate's complete gates before publishing it. Count executed tests and required scenarios; a missing database/runtime is “not qualified,” not a passing scenario.
|
||||
|
||||
### R9 — Large optional surfaces compete with core refinement
|
||||
|
||||
**Product-scope judgment, not proof of unused features.** The extension core, manager, controller, and main view alone span 6,134 lines. Selective state transfer's service is 1,799 lines. `Core/Intelligence`, its controllers, and recommendation services together span 7,762 lines before their frontend/tests. Legacy configuration code is 2,257 lines.
|
||||
|
||||
These figures are overlapping feature-area samples, not additive deletion estimates. There is no usage telemetry in this audit that establishes users do not need these features. History, local recommendations, permissions, and backup logic can be valuable. The problem is making their entire advanced scope a prerequisite for reliable local listening and acquisition.
|
||||
|
||||
**Action:** freeze advanced feature development; make experimental surfaces opt-in and exclude unqualified promises from release marketing. Keep data readable and recovery possible. Remove a whole optional subsystem only after identifying its consumers, persisted data, dependencies, and an export/migration path.
|
||||
|
||||
### R10 — Churn needs boundaries, not another whole-app rewrite
|
||||
|
||||
**Confirmed concentration; performance impact requires measurement.** Global styles, playlist controllers, the API module, and shared projections are frequent change targets. Match review loads up to 10,000 snapshots plus related decisions/identity/library rows before projection; the method is bounded, but that is not proof of an inexpensive page request. The download list recursively enumerates audio files. These are sensible scale-test targets, not established latency regressions.
|
||||
|
||||
**Action:** freeze the visual system and feature scope during stabilization. Consolidate repeated component rules and remove obsolete selectors against existing responsive tests. Measure representative libraries/history first, then page/filter at the authoritative store where needed. Split large owners only around real responsibilities; moving code into more files does not count as code reduction. Keep explanatory safety/protocol comments, delete redundant narration only while touching its owner.
|
||||
|
||||
## What to remove, simplify, or defer
|
||||
|
||||
| Category | Candidate | Required boundary |
|
||||
| --- | --- | --- |
|
||||
| Remove after final reference check | Orphan `PlaylistSyncService`, registration, obsolete constructor test; unused local scan/status convenience methods | Preserve the actual Subsonic playlist gateway and authenticated refresh service. |
|
||||
| Consolidate, then delete old path | Typed versus legacy stream/download orchestration; direct cache promotion versus managed placement | Prove media bytes, ranges, quality, scope, restart behavior, and artifact references first. |
|
||||
| Deprecate | Old `SpotifyImport:Playlists` mutation API and duplicated option state | Identify consumers; migrate or return an explicit supported replacement. |
|
||||
| Simplify presentation | Playlist mode combinations, source/account terminology, raw matching diagnostics, advanced routing | Keep simple defaults plus advanced controls. Do not change stored meanings during a UI cleanup. |
|
||||
| Defer the workspace | Intelligence history/imports/discovery/automation and AudioMuse workbench | Navigation hidden now; finish release route/bundle and entry-point isolation without losing existing data or consent controls. |
|
||||
| Defer expansion | Extension marketplace/SDK ecosystem and cross-provider artist/album catalog merging | No new release dependency; qualify any retained extension path. |
|
||||
| Keep as advanced maintenance | Selective transfer and legacy-env conversion | Full backup, restore, key handling, and migration integrity remain mandatory. |
|
||||
| Keep and finish | Local/native parity, exact identity, manual decisions, personal accounts, explicit retention, local reconciliation | These directly serve the product contract. |
|
||||
| Never count as expendable bulk | Isolation, cancellation, retries, ownership/path checks, migrations, tested compatibility behavior | Shorter code is not an improvement if these guarantees disappear. |
|
||||
|
||||
Do not set a whole-repository percentage deletion quota from these figures. Each consolidation change should state which implementation/route/setting was removed, its net production-line change, and which user guarantees still pass. File moves, compressed formatting, removed assertions, and deleted migration snapshots do not qualify as an optimization.
|
||||
|
||||
## WebUI fix log (2026-09-13)
|
||||
|
||||
**Implementation integrity verdict: not release-ready.** The shared components and visual tokens are worth retaining, but verified cascade and tab-sizing defects still undermine them. The Impeccable static detector returned no findings for `webui/src`; its source-pattern scan did **not** detect the runtime defects below. A green detector or no-document-overflow test is not sufficient visual qualification.
|
||||
|
||||
Review method: production build in the built-in browser, using the repository's existing read-only API fixtures. Inspected Home, playlists and details, mappings, mobile Settings, Services/Routing, Cached, and the More sheet. Browser-reported CSS widths included 390, 853, and 1280 pixels. Inspected dark and light states and keyboard skip/navigation behavior. The deployed admin endpoint returned 403 to a read-only check; no access policy was changed. This is not a live-database, real-iOS, or full accessibility certification.
|
||||
|
||||
### Provisional audit health
|
||||
|
||||
| Dimension | Score / 4 | Evidence and limit |
|
||||
| --- | --- | --- |
|
||||
| Accessibility | 3 | Named controls, working keyboard skip, shared keyboard tabs/dialogs; duplicate primary navigation and overlapping targets remain. Full contrast/assistive-technology coverage not performed. |
|
||||
| Performance | 3 | Production budgets pass: 72.8 KiB initial JS and 25.0 KiB CSS compressed. Large-list refresh and real-network frame/interaction timing remain unqualified. |
|
||||
| Responsive design | 2 | Mobile route tests pass, but desktop compact tab containers overlap and desktop navigation duplicates. Viewport width alone misses these states. |
|
||||
| Theming | 3 | Reviewed light/dark use the incumbent token system; no palette replacement needed. Contrast across every state still needs measurement. |
|
||||
| Implementation integrity | 2 | Shared owners exist, but conflicting global selectors and ambiguous storage/status copy weaken the product contract. |
|
||||
| **Total** | **13 / 20** | **Acceptable foundation; significant work required before release.** |
|
||||
|
||||
Seven open findings: **0 P0, 3 P1, 4 P2, 0 P3**. P1 means fix before release; P2 is an actionable usability improvement, not a redesign request. UI-7 is a release gate rather than a newly introduced runtime defect.
|
||||
|
||||
| ID / priority | Verified problem and user impact | Owner / reproduction | Planned fix and passing regression |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-1 **P1** | Desktop shows both primary navs, repeating Home/Library/Activity and pushing account controls down. | [`app.css`](../webui/src/app.css), `.sidebar nav` versus `.mobile-navigation`; both compute to `display:grid` at 853 and 1280 CSS px. | Scope layout rules to the intended navigation so the hidden variant stays hidden. At 760/761/900/901/1280, exactly one Primary nav is visible and keyboard-reachable, including expanded/slim sidebars. |
|
||||
| UI-2 **P1** | Playlist-detail view tabs overlap at desktop width; labels and clickable rectangles intrude into neighbors despite no document overflow. | [`SegmentedNav`](../webui/src/lib/components/SegmentedNav.svelte), [`app.css`](../webui/src/app.css), `PlaylistsView` “What listeners see.” At 1280 px the first two tab rectangles were approximately 815–967 and 943–1139; second/third labels overlap too. Equal `minmax(0,1fr)` grid tracks conflict with `min-width:max-content` children. | Fix shared tab sizing based on the available container, not only the viewport breakpoint. Preserve scrolling/keyboard selection. Assert nonintersecting tab **and label** rectangles for long provider names/counts in narrow desktop dialogs and mobile tabs. |
|
||||
| UI-3 **P2** | Home's individual activity rows all lead to the generic event page, losing the selected event/context. | [`HomeView`](../webui/src/lib/components/HomeView.svelte), `.activity-line` always links to `#/activity`; event view supports related-object links but not selection from Home. | Carry the event/correlation into Activity and reveal it, including older events or an explicit unavailable state. Keep “View all” generic. Regression clicks a specific row and finds that event, not merely the Activity heading. |
|
||||
| UI-4 **P2** | Match filtering requires a raw “Library scope” string; ordinary users cannot select their library by name. Snapshot IDs and repeated confidence displays compete with the decision itself. | [`MappingView`](../webui/src/lib/components/MappingView.svelte), filter input, comparison metadata, confidence summary. Confirmed in the rendered fixture and source. | Reuse the authorized media-target picker with names and an All libraries choice; retain IDs in technical disclosure. Preserve precise confidence/evidence in one clear hierarchy. Test multiple allowed libraries, no allowed library, and long metadata. |
|
||||
| UI-5 **P2** | Cached/Kept labels blur server retention, browser export, and native indexing. “Indexed” totals actually render `managedCount`, which includes every non-diagnostic entry, not observed native items. | [`DownloadsView`](../webui/src/lib/components/DownloadsView.svelte), totals/actions; [`DownloadsController`](../allstarr/Controllers/DownloadsController.cs), `GetDownloads`. | Call the current total Managed (or another accurate term); explain browser file export versus keeping on server. Add separate waiting-for-index/native-available state only after R3–R4 establish it. Regression proves a cached/unindexed file is never presented as available in the native library. |
|
||||
| UI-6 **P2** | Global live status exposes “Stale”/“Reconnecting” without explaining which data is old, when it last updated, or what the user can do. | [`+page.svelte`](../webui/src/routes/%5B...path%5D/+page.svelte), `.live-state`. The no-SSE fixture exercises this UI; it is **not** evidence of a production stream outage. | Reuse shared live-update state for an accessible freshness explanation, last-success time, and appropriate recovery action. Preserve automatic backoff; test interruption/recovery without discarding drafts or duplicating refresh work. |
|
||||
| UI-7 **P1** | Hidden Intelligence still has an importable route, an AudioMuse “Configure in Intelligence” link, and related UI/data-control dependencies. Hiding one tab alone is not the agreed release boundary. | Shell loader, [`SourcesView`](../webui/src/lib/components/SourcesView.svelte), Home aggregates, docs; see the scope decision above. | Complete one coherent release/development boundary and legacy-link behavior. Production build cannot load the deferred workspace or advertise its unavailable workflows; current users retain a documented data-management path. Core playlist import, playback and scrobbling keep working. |
|
||||
|
||||
**Preserve:** responsive tables, equal mobile navigation tracks, shared controls, explicit destructive confirmations, scoped account terminology, keyboard navigation, skeleton/error states, reduced-motion handling, both themes, and route-level lazy loading. The keyboard skip link correctly advances into main content; it was checked and is not a finding.
|
||||
|
||||
**Test limitations to address:** the fixture can deliberately report a Review count of zero while returning a tentative row, and different source versus client-projection route facts. Those contradictions were not logged as proven production bugs. Add faithful server-contract scenarios before judging auto-acceptance or native availability. Existing route geometry tests mostly measure viewport containment and touch sizes: add desktop hidden-variant and sibling-intersection assertions, then real-API journeys and WebKit. Separately measure long lists/live insertions, slow/error/empty states, 200% zoom, and text contrast; do not claim smoothness from compressed-byte budgets.
|
||||
|
||||
**Recommended passes:** `$impeccable adapt` for UI-1/2; `$impeccable harden` for UI-7 and freshness recovery; `$impeccable clarify` for UI-3/4/5/6; `$impeccable optimize` only against measured list/refresh bottlenecks; finish with `$impeccable polish`. Run these independently or together, then rerun `$impeccable audit`. No replacement palette or whole-app reskin is needed.
|
||||
|
||||
## Execution plan
|
||||
|
||||
The ordering below is intentional. Estimates should be made after the first clean candidate and core regressions exist; the exit conditions are more useful than a speculative release date.
|
||||
|
||||
| Order | Work package and owner | Concrete deliverable / exit condition |
|
||||
| --- | --- | --- |
|
||||
| 0 | Reconcile the candidate; repository/release owner | Classify the 211 outstanding paths, retain/defer coherently, commit a clean source candidate, and run all required gates. No hidden untracked implementation dependencies. Record application and sidecar image provenance. |
|
||||
| 0a | Freeze release scope and fix shared shell/tab defects; WebUI/release owner | Intelligence navigation removal is done; complete UI-7 release isolation. Correct UI-1/2 once in shared owners, with desktop/mobile hidden-variant and nonoverlap regressions. No palette redesign. |
|
||||
| 1 | Define and reproduce core failures; playback/files owners | Deterministic reproductions for cross-user warm-cache access policy, configured kept-root visibility, interrupted promotion, and acquisition becoming a native item. Keep them red until the shared cause is corrected. |
|
||||
| 2 | Finish acquisition and local handoff; Downloads, ManagedFiles, Enrichment, Matching | One managed placement policy and projection; favorite/playlist/manual keep requests reuse it. Durable scan/index observation, identity reconciliation, and native replay work. Cache eviction and unfavorite cannot remove kept/native originals. |
|
||||
| 3 | Consolidate playback and personal access; Protocols, Routing, provider adapters | Scoped cache authorization; one typed playback route with explicit pre-commit fallback outcomes and retained verified alternatives as specified above; durable acquisition separated from client streaming. Remove superseded legacy orchestration and orphan code after coverage passes. |
|
||||
| 4 | Stabilize playlists and essential dashboard interactions; Playlists/WebUI | Import-once/linked and on-demand/keep-all work for two users. Names, art, order, unavailable tracks, manual authority, and rematching remain correct after restart. Kept status and local availability reflect authoritative records. Resolve UI-3–6; reduce duplicated presentation rules without redesigning the app. |
|
||||
| 5 | Qualify and package; test/release owner | Same clean candidate passes deterministic, PostgreSQL, real-API browser, protocol/live provider, timing, install/update/restore, and soak gates. Publish versioned compatibility results and an immutable image. Start a controlled pilot. |
|
||||
|
||||
Work packages 2 and 3 must converge before multi-user distribution. Keep changes reviewable: one responsibility or retirement per commit, regression first for a known failure, no formatting sweep mixed into ownership changes. Preserve unrelated work during candidate reconciliation; do not reset the working tree to obtain “clean” status.
|
||||
|
||||
### Qualification matrix to extend in the existing kit
|
||||
|
||||
| Journey | Required variants | Pass condition |
|
||||
| --- | --- | --- |
|
||||
| Native listening | Jellyfin and Subsonic claims; desktop/mobile client; optional providers healthy/down | Authentication, art, browse, search, playback, seeking, and playlists preserve native behavior. Zero external media fetches for a known local recording. |
|
||||
| External listening | Every provider advertised for audio; cold/warm, GET/HEAD, real ranges, lossy/original, cancellation, expired credentials, rate limit | Correct playable audio and content facts; bounded startup/failure; no guessed cross-provider identity or global credential fallback. |
|
||||
| Explicit acquisition | Manual keep, favorite opt-in, playlist keep-all; duplicate requests; restart during download/place; configured custom root | Exactly one owned retained artifact per intended identity/scope, usable tags, visible status, native index reconciliation, replay without the external service. |
|
||||
| Account isolation | Two users, separate/shared policy, same external ID, revoked account, background jobs, warm cache | Personal credentials/artifact access and work remain within the authorized scope; intentional shared local access remains usable. |
|
||||
| Playlist import | Spotify and each advertised alternative; one-time/linked; art/name/order changes; unresolved rows; source account disabled | Frozen copies stay frozen, linked copies update as chosen, unavailable songs are explained, no silent source mutation or duplicate materialization. |
|
||||
| Matching | Local/external competition, alternate recordings, manual pin/reject, algorithm rollout, newly indexed download | Correct recording, raw confidence preserved, manual authority retained, all read views agree on the effective route. |
|
||||
| Playback observation / scrobbling | Native and external playback; start/progress/stop/repeat; opt-in/off; source outage | Retained core now-playing and scrobble behavior is correct and scoped; disabled collection remains off and optional failures cannot break audio. Intelligence charts/history/import qualification belongs to its deferred milestone. |
|
||||
| Browser workflows | Real API plus existing mocked UI suite; 320–430 px and desktop; keyboard, dark/light, reduced motion; WebKit for an iOS claim | Connect, import, review, keep, inspect failure, retry, and sign out complete without overflow, inaccessible controls, or misleading success. |
|
||||
| Recovery | Fresh install; restart; PostgreSQL interruption; full backup/key restore into disposable deployment; separately preserved media | Candidate recovers accounts/state and reconciles artifacts without duplicate destructive work. Original backend media remains unchanged. |
|
||||
|
||||
Use a provider-neutral scenario definition and protocol adapters within `tools/tests/`; reuse the existing .NET fixtures, fake HTTP providers, and isolated PostgreSQL harness. Do not introduce another testing framework. Live provider runs are an explicit qualification lane with operator-supplied secrets, never a requirement for normal CI.
|
||||
|
||||
For timing, record click/request → authentication → route/lease → first actual audio bytes → playable decode, with warm/cold state and provider noted. Include queue delay and p50/p95/max across repeated runs, not one successful curl. A proposed target for the reported three-second client limit is p95 below 2.5 seconds with margin; it is **not a measurement or a promise that cold Apple preparation meets it**. Retain a failing provider/client combination as experimental until the path meets the limit or compatibility is accurately documented. Headers/empty data alone do not prove the client received usable media.
|
||||
|
||||
### First release and pilot scope
|
||||
|
||||
Recommend a small private beta first: Jellyfin 12 with explicitly tested desktop/mobile clients, the providers that pass the full audio/acquisition matrix, and personal playlist import. Keep Subsonic available as a preview if its live qualification lags; qualify it equivalently before marketing both protocols as equally supported. Do not remove it to reduce the line count.
|
||||
|
||||
Use a clean install and a restored install, then a small group of non-admin users for a multi-day pilot. Require working native playback, successful external-to-local acquisition, correct personal-account isolation, actionable failures, and tested recovery. Any unexplained wrong-account access, native playback breakage, lost retained file, or corruption blocks wider distribution.
|
||||
|
||||
The maintainer's main decisions are the supported backend/client/provider matrix, whether acquiring music is an explicit keep action or an opt-in favorite policy, and which advanced surfaces remain experimental. Recommended default: **play on demand; retain only on explicit keep or an explicit playlist/favorite retention policy; prefer a verified local recording once available**. Acquisition policy must not be an accidental side effect of selecting an audio provider.
|
||||
|
||||
## Verification performed for this assessment
|
||||
|
||||
- Current working-tree Release build succeeded with warnings treated as errors: zero warnings/errors. The initial sandboxed multiprocess invocation ended after five minutes without a compiler diagnostic; the single-process build outside that sandbox completed successfully. This was not classified as an application build defect.
|
||||
- 114 focused .NET tests passed, zero skipped: matching decisions, provider routing, protocol support policy, current support catalog, managed stream cache, and file placement.
|
||||
- `npm run check`: zero errors/warnings. `npm test`: 47 tests passed across 11 files.
|
||||
- GitHub reports [CI success for committed `adf3c585a`](https://github.com/SoPat712/allstarr/actions/runs/34603953662). That result applies to the committed source, not the dirty tree audited here.
|
||||
- Full PostgreSQL lanes, current-tree production WebUI build/budgets/browser runs, live backend/provider tests, timing, and restore rehearsal were not rerun for this assessment. No production database, account, or media state was changed. Findings above distinguish code-confirmed gaps, untested runtime risks, and product-scope recommendations.
|
||||
|
||||
The next implementation step is to reconcile the candidate and reproduce R2–R4, then complete the shared acquisition and local-playback path. Advanced features and cosmetic work should remain frozen until that path is qualified.
|
||||
|
||||
### WebUI addendum validation
|
||||
|
||||
- Navigation change: one shared destination filter; content-sized equal mobile tracks. No backend, account, media, migration, or data changes.
|
||||
- `npm run check`: zero errors/warnings. `npm test`: 47 passed. Production build and all bundle budgets passed.
|
||||
- Focused production-build browser checks cover all 13 existing route entries at six viewports plus navigation, breakpoints, and keyboard/contextual-tab behavior. Light-theme run: 83 passed; dark-theme run: 83 passed. Deferred Intelligence direct-route tests are retained, not disabled to obtain a green result. Existing keyboard/breakpoint checks supplement the theme-specific route runs.
|
||||
- The initial browser run found one remaining old destination-count assertion; it was updated to the intentional three mobile links/five desktop links. That was a test expectation change caused by this navigation removal, not a dismissed product defect.
|
||||
- Runtime audit findings above remain **planned**, except the explicitly completed Intelligence navigation removal and adaptive mobile track sizing. No claim of full UI repair, live-backend qualification, or deployment is made.
|
||||
|
||||
### Account sharing and server qualification addendum
|
||||
|
||||
Owner-controlled sharing is implemented in the working tree, not deployed: listeners choose **Private** by default or explicitly confirm **Global**, and can later unshare, disable, replace credentials, or remove their own connection. Management stays owner-bound; consumption follows the shared provider policy. Private-to-global transitions retain the creator and transactionally rebind the encrypted secret. Administrator-assigned private connections cannot be reshared by their recipient or reclaimed by their original creator. No schema migration or parallel credential system was added.
|
||||
|
||||
The shared ownership query also covers Last.fm connection completion. Playlist discovery and routing now use the same global-personal-capability policy, preserving the creator's own personal access without opening it to peers. Non-admin audience and enable/disable routes pass through authentication middleware to scoped controllers. Operator-only provider probes remain restricted; listener saves no longer misreport a forbidden probe as a broken credential. Existing dialogs and theme tokens were reused, with explicit consent, quota/privacy copy, and responsive controls rather than a redesign.
|
||||
|
||||
Verification uses a source snapshot on the operator's server and a disposable PostgreSQL 18 database on a private Docker network, with no published database port or production Docker socket in the runner. It does not mutate the live database to exercise sharing. Results:
|
||||
|
||||
- Release build: zero warnings/errors. Focused account, identity, routing, secret-store, and scrobbling regressions: **108 passed, zero skipped**. These cover share/unshare, encrypted-secret rebinding, denied peer mutations and personal access, disabled accounts, operator policy, stale revisions, assigned ownership, and shared Last.fm authentication.
|
||||
- WebUI check: zero errors/warnings; **47 unit tests passed**; production build and bundle budgets passed. Account creation, consent, sharing/unsharing, administrator dialogs, keyboard/reduced-motion behavior, and responsive account pages: **31 Chromium checks passed in each theme**, six viewports. Fixture-only screenshots were visually checked at narrow mobile and desktop sizes. These are not live API or mobile Safari qualification.
|
||||
- Full ordinary .NET lane: **2,308 passed, three failed, zero skipped**. The failures are `DeezerMetadataServiceTests.MetadataSearch_PropagatesCallerCancellation` for songs, albums, and artists: each single-query search catches cancellation and returns an empty result. Preserve the tests; propagate caller cancellation while retaining bounded handling of genuine provider failures before release. Compose prerequisites were supplied to the runner and all Compose contract checks passed on rerun.
|
||||
- Release-critical .NET lane: **105 passed, zero failed/skipped**, including PostgreSQL-native backup verification and restore into an isolated database after installing the version-matched client tools in the disposable runner. Across both full lanes: **2,413 passed, three failed, zero skipped**. No assertion or required test was disabled to obtain these results.
|
||||
- Live native-protocol smoke succeeded with the designated test account: sign-in, three audio listings, item detail, album artwork, a 65,536-byte HTTP 206 stream, and logout. Server-local timings were approximately 219 ms sign-in, 290 ms median browse, 41 ms detail, 128 ms artwork, and 56 ms audio range. These are warm, server-origin measurements—not a WAN/mobile or cold-provider-start benchmark.
|
||||
- Live dashboard loads through the LAN address but the test-account sign-in reports **“Failed to authenticate with Jellyfin”**, despite successful protocol sign-in. Root cause is not yet established; do not claim live dashboard/account qualification. Loopback dashboard access returns 403 under the existing network policy; no access rule was relaxed. The deployed app is not this working-tree snapshot.
|
||||
|
||||
Remaining account work, in release order:
|
||||
|
||||
1. Fix and retest the live dashboard sign-in path with the same candidate that will be released; complete a real UI → API → PostgreSQL two-user sharing round trip in staging.
|
||||
2. Close **R2**: reauthorize warm/cached media and durable work after unsharing, disabling, or revocation. Passing account-resolution tests does not prove media-byte isolation.
|
||||
3. Define account-owner disable/deletion behavior and expose account usage/audit records to its owner without leaking other listeners' private history.
|
||||
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.
|
||||
|
||||
Playback source observations feed Home, per-user/device Jellyfin song details, and durable listening attribution. Artwork remains unchanged because a shared cached cover cannot reliably name a listener's current stream. [Client compatibility](operations/client-compatibility.md#external-song-labels-and-playback-sources) owns the detailed behavior and limitations.
|
||||
|
||||
Server qualification used a source snapshot and disposable PostgreSQL, never production database mutations or a deployment:
|
||||
|
||||
- Release build: zero warnings/errors.
|
||||
- Full ordinary .NET lane: **2,342 passed, zero failed/skipped**. Full release-critical lane: **105 passed, zero failed/skipped**, including database backup/restore and Compose contracts. The three previously failing Deezer caller-cancellation cases now pass.
|
||||
- Frontend: zero check errors/warnings; **47 unit tests passed**; production build and bundle budgets passed. **Seven focused browser tests passed**, covering confirmed/cached/unknown source labels in light/dark themes at mobile and desktop sizes, plus Home runtime/request budgets. Mobile screenshots were visually reviewed.
|
||||
- Regression coverage includes exact alternative IDs and configured ordering, pre-response failures and response disposal, cancellation/deadlines, one-retry bounds, authentication stops, manual authority, warm-cache denial, serving-identity publication, source scope, range continuation, HEAD neutrality, consistent title markers, source details, and correction of provisional listening attribution.
|
||||
|
||||
Remaining boundaries: no canonical catalog-ID migration or cross-provider artist/album merging; no speculative matching on the stream critical path; no cross-provider failover for clients without a device identifier (standard Subsonic application-name-only requests retain exact-provider playback). In-memory seek selections expire and disappear on restart, requiring a fresh playback start. Live personal-provider failover and client-specific display behavior still require deployment qualification. R2's scoped artifact ownership and R3–R4's kept/native acquisition lifecycle remain release work.
|
||||
+1
-1
@@ -36,7 +36,7 @@ The source is explicitly marked as confirmed streaming, cached audio, or an unco
|
||||
|
||||
### Intelligence (deferred)
|
||||
|
||||
Intelligence is not in the desktop navigation, mobile bar, or More sheet while the first release is being prepared. Existing `#/intelligence` links still open the development workspace. This navigation change does not delete history, disable existing opt-in jobs, or disconnect accounts. The [release plan](release-readiness.md#first-release-scope-decision-2026-09-13) tracks the remaining release-exclusion work; the following describes the retained workspace, not a first-release promise.
|
||||
Intelligence is not in the desktop navigation, mobile bar, or More sheet while the first release is being prepared. Existing `#/intelligence` links still open the development workspace. This navigation change does not delete history, disable existing opt-in jobs, or disconnect accounts. The following describes the retained workspace, not a first-release promise.
|
||||
|
||||
- **Overview** shows live playback, listening totals, and an interactive daily or monthly activity map for the selected library. Long and all-time ranges add an activity-year selector, default to the busiest imported year, and separate imported history from direct playback in each bucket.
|
||||
- **History** searches, filters, corrects, exports, or removes retained listening events.
|
||||
|
||||
Reference in new issue
Block a user