fix(ui): clarify playlist routes and navigation
CI / build-and-test (push) Canceled after 0s
CI / release-critical-tests (push) Canceled after 0s
CI / csharp-format (push) Canceled after 0s
CI / webui (push) Canceled after 0s
CI / apple-contracts (push) Canceled after 0s
CI / compose-contracts (push) Canceled after 0s
CI / release-manifest (push) Canceled after 0s

This commit is contained in:
joshpatra committed 2026-09-13 21:52:04 -04:00
1 parent 881bc57fe1
commit 736ebcf899
22 files changed
+452 -136

No files matched your search

+2
View File
@@ -36,6 +36,7 @@ Use Google Material 3 as the interaction and visual grammar, adapted to Allstarr
- Regular controls use the 44px `--control-md` baseline. Labels, controls, helper text, and footer actions align within a shared field grid.
- The compact bottom navigation reserves `--mobile-nav-height` on the scrolling workspace so terminal content and actions remain fully visible above it.
- The compact bottom navigation keeps Home, Library, Activity, and **More** in equal-width tracks derived from its contents. Integrations, Settings, appearance, and session controls live in the More sheet instead of crowding the primary bar.
- Only one primary navigation is visible at a time: the sidebar above 760px, or bottom navigation at 760px and below. Desktop sidebar styles must not target the mobile navigation. The More sheet closes when leaving the compact layout and restores focus to a visible navigation control.
- Data surfaces grow with sparse content and cap their height only when a real list needs internal scrolling. Empty viewport-filling panels are not used as decoration.
## Information architecture
@@ -66,4 +67,5 @@ Use Google Material 3 as the interaction and visual grammar, adapted to Allstarr
- Core tasks work at 320px, keyboard-only, reduced-motion, light, and dark themes.
- Status is never color-only.
- Tables share gutters, row heights, alignment, and action placement.
- Playlist details distinguish the complete mapped listener view from the native backend playlist. Routes show one active choice plus verified alternatives; native materialization eligibility is supporting detail, not part of the track title.
- Shared-system changes should delete route-specific CSS over time and keep the current JS/CSS budgets green.
@@ -222,6 +222,106 @@ public sealed class TrackMatchDecisionEngineTests(ITestOutputHelper output)
Assert.Contains("ambiguous_top_candidates", decision.Warnings);
}
[Fact]
public void ReorderedDuetCreditsOnDifferentReleases_DoNotCreateAmbiguity()
{
var scope = Scope();
var source = Source() with
{
Title = "Dream A Little Dream Of Me - Single Version",
Artist = "Ella Fitzgerald, Louis Armstrong",
Album = "Love, Ella",
AlbumArtist = null,
DurationMilliseconds = 185_160
};
var first = ProviderCandidate(scope, "apple-download", 186_000) with
{
Title = "Dream a Little Dream of Me",
Artist = "Louis Armstrong & Ella Fitzgerald",
Album = "Stranger Things: Soundtrack from the Netflix Series, Season 4"
};
var second = first with
{
LibraryTrackId = Guid.CreateVersion7(),
BackendItemId = "another-release",
Artist = "Ella Fitzgerald & Louis Armstrong",
Album = "Cheek To Cheek: The Complete Duet Recordings",
DurationMilliseconds = 187_000
};
var decision = new TrackMatchDecisionEngine().Decide(scope, source, [first, second]);
Assert.Equal(TrackMatchReviewState.Accepted, decision.State);
Assert.Equal(2, decision.Candidates.Count);
Assert.DoesNotContain("ambiguous_top_candidates", decision.Warnings);
Assert.True(TrackMatchDecisionEngine.SameRecordingIdentity(first, second));
}
[Theory]
[InlineData("Dream a Little Dream of Me", "Ella Fitzgerald & Louis Armstrong", 187_000, true)]
[InlineData("Dream a Little Dream of Me (Live)", "Ella Fitzgerald & Louis Armstrong", 187_000, false)]
[InlineData("Dream a Little Dream of Me (Instrumental)", "Ella Fitzgerald & Louis Armstrong", 187_000, false)]
[InlineData("Dream a Little Dream of Me", "Ella Fitzgerald", 187_000, false)]
[InlineData("Dream a Little Dream of Me", "Ella Fitzgerald & Another Singer", 187_000, false)]
[InlineData("Dream a Little Dream of Me", "Ella Fitzgerald & Louis Armstrong", 200_000, false)]
public void ReleaseEquivalence_PreservesVersionAndCreditDifferences(
string title, string artist, long duration, bool equivalent)
{
var first = ProviderCandidate(Scope(), "apple-download", 186_000) with
{
Title = "Dream a Little Dream of Me",
Artist = "Louis Armstrong & Ella Fitzgerald",
Album = "First compilation"
};
var second = first with { Title = title, Artist = artist, Album = "Another compilation", DurationMilliseconds = duration };
Assert.Equal(equivalent, TrackMatchDecisionEngine.SameRecordingIdentity(first, second));
}
[Fact]
public void KissMeMore_VocalFixtureIsAccepted_InstrumentalFixtureIsNotEquivalent()
{
var scope = Scope();
var source = Source() with
{
SnapshotId = "fixture-kiss-me-more",
ProviderId = "fixture-provider",
ExternalId = "fixture-kiss-me-more-vocal",
Title = "Kiss Me More",
Artist = "Doja Cat feat. SZA",
Album = "Planet Her",
AlbumArtist = "Doja Cat",
DurationMilliseconds = 208_000
};
var vocal = Candidate(scope) with
{
LibraryTrackId = Guid.CreateVersion7(),
BackendItemId = "fixture-kiss-me-more-vocal",
CanonicalRecordingId = null,
Title = "Kiss Me More",
Artist = "Doja Cat feat. SZA",
Album = "Planet Her",
AlbumArtist = "Doja Cat",
DurationMilliseconds = 208_000,
ProviderTrackIds = null,
IsLocal = false
};
var instrumental = vocal with
{
LibraryTrackId = Guid.CreateVersion7(),
BackendItemId = "fixture-kiss-me-more-instrumental",
Title = "Kiss Me More (Instrumental)"
};
var engine = new TrackMatchDecisionEngine();
var vocalDecision = engine.Decide(scope, source, [vocal]);
var instrumentalDecision = engine.Decide(scope, source, [instrumental]);
Assert.Equal(TrackMatchReviewState.Accepted, vocalDecision.State);
Assert.NotEqual(TrackMatchReviewState.Accepted, instrumentalDecision.State);
Assert.False(TrackMatchDecisionEngine.SameRecordingIdentity(vocal, instrumental));
}
[Fact]
public void HigherScoringCandidateOutsideNarrowMarginWins()
{
@@ -126,6 +126,25 @@ public sealed class DurablePlaylistProjectionTests
Assert.Equal(nativeJson, track.NativeEntryJson);
}
[Theory]
[InlineData("local", TrackRouteKind.Local)]
[InlineData("external", TrackRouteKind.External)]
[InlineData("unmatched", TrackRouteKind.Unresolved)]
public void ResolvedProjection_PreservesSourceExplicitnessForFallbackTitles(string route, TrackRouteKind expected)
{
var metadata = new PlaylistSourceMetadata(IsExplicit: true);
var entry = Entry(route, TrackMatchState.Accepted,
route == "local" ? "native" : null, [new("deezer", "external")]);
var track = PlaylistVirtualizationService.ToResolvedVirtualTrack(entry,
new Dictionary<string, LibraryTrackRecord>
{
["native"] = new() { BackendItemId = "native", Title = "Native title" }
}, "spotify", sourceMetadata: metadata);
Assert.Same(metadata, track.SourceMetadata);
Assert.Equal(expected, track.RouteKind);
}
private static ProjectionRow Row(string id, params string[] alternates) => new(id, alternates);
private sealed record ProjectionRow(string Id, IReadOnlyList<string> AlternateRoutes);
@@ -2616,6 +2616,10 @@ public sealed class ProtocolRouteFixtureTests
Assert.All(playlist.RootElement.GetProperty("Items").EnumerateArray(),
item => Assert.False(string.IsNullOrWhiteSpace(item.GetProperty("Id").GetString())));
var first = playlist.RootElement.GetProperty("Items")[0];
Assert.Equal("Babydoll", first.GetProperty("Name").GetString());
foreach (var response in new[] { playlist.RootElement, parentItems.RootElement })
Assert.All(response.GetProperty("Items").EnumerateArray().Skip(1), item =>
Assert.EndsWith(" [A]", item.GetProperty("Name").GetString(), StringComparison.Ordinal));
Assert.Equal(nativeId, first.GetProperty("ParentId").GetString());
Assert.Equal("source-a", first.GetProperty("MediaSources")[0].GetProperty("Id").GetString());
Assert.Equal(
@@ -2797,7 +2801,7 @@ public sealed class ProtocolRouteFixtureTests
Assert.Empty(unresolved.GetProperty("MediaSources").EnumerateArray());
var stale = tracks.RootElement.GetProperty("Items")[2];
Assert.Equal("allstarr-unresolved-stale-local-song", stale.GetProperty("Id").GetString());
Assert.Equal("Stale Track [S]", stale.GetProperty("Name").GetString());
Assert.Equal("Stale Track [A]", stale.GetProperty("Name").GetString());
Assert.Equal("None", stale.GetProperty("PlayAccess").GetString());
Assert.False(stale.GetProperty("CanDownload").GetBoolean());
Assert.Empty(stale.GetProperty("MediaSources").EnumerateArray());
@@ -37,6 +37,7 @@ public sealed class VirtualPlaylistProtocolAdapterTests
Assert.Equal(3, items[0].GetProperty("IndexNumber").GetInt32());
Assert.Equal("jellyfin-local-a", items[1].GetProperty("Id").GetString());
var unresolved = items[2];
Assert.Equal("Missing [A]", unresolved.GetProperty("Name").GetString());
Assert.Equal("allstarr-unresolved-source-hash", unresolved.GetProperty("Id").GetString());
Assert.Equal("None", unresolved.GetProperty("PlayAccess").GetString());
Assert.False(unresolved.GetProperty("CanDownload").GetBoolean());
@@ -136,7 +137,7 @@ public sealed class VirtualPlaylistProtocolAdapterTests
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result.Value));
var item = json.RootElement.GetProperty("Items")[0];
Assert.Contains("Source title", item.GetProperty("Name").GetString(), StringComparison.Ordinal);
Assert.Equal("Source title [A]", item.GetProperty("Name").GetString());
Assert.Contains("Source artist", item.GetProperty("Artists")[0].GetString(), StringComparison.Ordinal);
Assert.Contains("Second artist", item.GetProperty("Artists")[1].GetString(), StringComparison.Ordinal);
Assert.Equal("Virtual", item.GetProperty("LocationType").GetString());
@@ -199,6 +200,49 @@ public sealed class VirtualPlaylistProtocolAdapterTests
gateway.VerifyAll();
}
[Theory]
[InlineData(PlaylistProjectionMode.Resolved, TrackRouteKind.Unresolved, false)]
[InlineData(PlaylistProjectionMode.Resolved, TrackRouteKind.Unresolved, true)]
[InlineData(PlaylistProjectionMode.Resolved, TrackRouteKind.External, false)]
[InlineData(PlaylistProjectionMode.Resolved, TrackRouteKind.External, true)]
[InlineData(PlaylistProjectionMode.Source, TrackRouteKind.Local, false)]
[InlineData(PlaylistProjectionMode.Source, TrackRouteKind.Local, true)]
[InlineData(PlaylistProjectionMode.Source, TrackRouteKind.External, false)]
[InlineData(PlaylistProjectionMode.Source, TrackRouteKind.External, true)]
public async Task JellyfinPlaylistTitles_UseNeutralLabelsWithoutChangingIdentity(
PlaylistProjectionMode projection, TrackRouteKind route, bool isExplicit)
{
var model = Model() with
{
ProjectionMode = projection,
Tracks =
[
new(0, "fixture-item", "Track", "Artist", "Album", null, 1_000, null,
TrackMatchState.Accepted, "spotify", "source-item", route,
RouteProviderId: "deezer", RouteExternalId: "route-item",
SourceMetadata: new(IsExplicit: isExplicit))
]
};
var adapter = new JellyfinVirtualPlaylistProtocolAdapter(
new StubVirtualizationService(model), new StubJellyfinMutationResolver(null),
responseBuilder: new JellyfinResponseBuilder());
var result = Assert.IsType<JsonResult>(await adapter.ReadItemsAsync(
Context(ProtocolKind.Jellyfin), ProtocolId, CancellationToken.None));
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result.Value));
var item = Assert.Single(json.RootElement.GetProperty("Items").EnumerateArray());
Assert.Equal(isExplicit ? "Track [A]/[E]" : "Track [A]", item.GetProperty("Name").GetString());
Assert.Equal("fixture-item", item.GetProperty("Id").GetString());
Assert.Equal(projection == PlaylistProjectionMode.Source ? "Album [S]" : "Album [D]",
item.GetProperty("Album").GetString());
Assert.Equal("Track", model.Tracks[0].Title);
if (route == TrackRouteKind.Unresolved)
{
Assert.Equal("None", item.GetProperty("PlayAccess").GetString());
Assert.Empty(item.GetProperty("MediaSources").EnumerateArray());
}
}
[Fact]
public async Task SubsonicRead_PreservesJsonAndXmlOrderIncludingUnresolvedRows()
{
@@ -137,7 +137,7 @@ public sealed class TrackMatchPolicy
public sealed class TrackMatchDecisionEngine
{
public const string AlgorithmVersion = "priority-windows-v16";
public const string AlgorithmVersion = "priority-windows-v17";
private const double ScoreEpsilon = 0.0000001;
private readonly TrackMatchPolicy _policy;
@@ -644,8 +644,13 @@ public sealed class TrackMatchDecisionEngine
var rightTitle = FuzzyMatcher.NormalizeForMatching(
FuzzyMatcher.StripDecorators(right.Title));
var sameTitle = leftTitle.Equals(rightTitle, StringComparison.Ordinal);
var sameArtist = FuzzyMatcher.NormalizeForMatching(left.Artist)
.Equals(FuzzyMatcher.NormalizeForMatching(right.Artist), StringComparison.Ordinal);
var sameArtist = SplitArtists(left.Artist)
.Select(FuzzyMatcher.NormalizeForMatching)
.ToHashSet(StringComparer.Ordinal)
.SetEquals(
SplitArtists(right.Artist)
.Select(FuzzyMatcher.NormalizeForMatching)
.ToHashSet(StringComparer.Ordinal));
var sameAlbum = !string.IsNullOrWhiteSpace(left.Album) &&
!string.IsNullOrWhiteSpace(right.Album) &&
FuzzyMatcher.NormalizeForMatching(left.Album)
@@ -234,8 +234,10 @@ public sealed class PlaylistVirtualizationService(
if (backendIds.Any(id => !hydrated.ContainsKey(id))) return null;
nativeItems = hydrated;
}
var sourceByPosition = projection.SourceEntries.ToDictionary(item => item.Position);
var resolvedTracks = projection.Entries
.Select(item => ToResolvedVirtualTrack(item, libraryTracks, link.SourceProviderId, nativeItems))
.Select(item => ToResolvedVirtualTrack(item, libraryTracks, link.SourceProviderId, nativeItems,
sourceByPosition.GetValueOrDefault(item.Position)?.Metadata))
.ToArray();
var resolvedByPosition = resolvedTracks.ToDictionary(item => item.SourcePosition);
var sourceTracks = projection.SourceEntries
@@ -330,7 +332,8 @@ public sealed class PlaylistVirtualizationService(
DurablePlaylistEntryProjection entry,
IReadOnlyDictionary<string, LibraryTrackRecord> libraryTracks,
string sourceProviderId,
IReadOnlyDictionary<string, BackendPlaylistMember>? nativeItems = null)
IReadOnlyDictionary<string, BackendPlaylistMember>? nativeItems = null,
PlaylistSourceMetadata? sourceMetadata = null)
{
if (entry.BackendItemId != null &&
libraryTracks.TryGetValue(entry.BackendItemId, out var local))
@@ -338,6 +341,7 @@ public sealed class PlaylistVirtualizationService(
local.Album, local.AlbumArtist, local.DurationMilliseconds,
local.CoverArtReference, entry.MatchState ?? TrackMatchState.Unresolved,
sourceProviderId, null, TrackRouteKind.Local,
SourceMetadata: sourceMetadata,
NativeEntryJson: nativeItems?.GetValueOrDefault(local.BackendItemId)?.NativeEntryJson);
var artist = entry.Artists.FirstOrDefault();
if (entry.RouteKind == "external" && entry.RouteProviderId != null)
@@ -348,14 +352,14 @@ public sealed class PlaylistVirtualizationService(
entry.DurationMilliseconds, null,
entry.MatchState ?? TrackMatchState.Unresolved,
entry.RouteProviderId, entry.ExternalId, TrackRouteKind.External,
entry.RouteProviderId, entry.ExternalId);
entry.RouteProviderId, entry.ExternalId, SourceMetadata: sourceMetadata);
}
return new(entry.Position, $"{UnresolvedItemPrefix}{entry.ExternalId}",
entry.Title, string.IsNullOrWhiteSpace(artist) ? "Unknown Artist" : artist,
entry.Album, null,
entry.DurationMilliseconds, null, entry.MatchState ?? TrackMatchState.Unresolved,
sourceProviderId, null, TrackRouteKind.Unresolved);
sourceProviderId, null, TrackRouteKind.Unresolved, SourceMetadata: sourceMetadata);
}
internal static IReadOnlyList<VirtualPlaylistTrack> ToTargetVirtualTracks(
@@ -6,7 +6,10 @@ public static class ExternalTrackPresentation
{
public static string Title(Song song) => song.IsLocal
? song.Title
: $"{song.Title} {(song.ExplicitContentLyrics == 1 ? "[A]/[E]" : "[A]")}";
: Title(song.Title, song.ExplicitContentLyrics == 1);
public static string Title(string title, bool isExplicit) =>
$"{title} {(isExplicit ? "[A]/[E]" : "[A]")}";
public const string PlaybackDescription =
"Injected by Allstarr. Playback uses an available verified source; the catalog provider is not necessarily the streaming provider.";
@@ -267,7 +267,7 @@ public sealed class JellyfinVirtualPlaylistProtocolAdapter(
track.BackendItemId);
item = JsonSerializer.SerializeToNode(FallbackItem(track))!.AsObject();
item["Id"] = $"{PlaylistVirtualizationService.UnresolvedItemPrefix}{track.BackendItemId}";
AddSourceLabels(item, track.SourceProviderId);
AddSourceLabels(item, track, track.SourceProviderId);
item["LocationType"] = "Virtual";
item["PlayAccess"] = "None";
item["CanDownload"] = false;
@@ -297,7 +297,8 @@ public sealed class JellyfinVirtualPlaylistProtocolAdapter(
: null,
IsLocal = false,
ExternalProvider = track.RouteProviderId ?? track.SourceProviderId,
ExternalId = track.RouteExternalId ?? track.SourceExternalId
ExternalId = track.RouteExternalId ?? track.SourceExternalId,
ExplicitContentLyrics = track.SourceMetadata?.IsExplicit == true ? 1 : 0
};
item = JsonSerializer.SerializeToNode(responseBuilder.ConvertSongToJellyfinItem(song))!.AsObject();
}
@@ -310,7 +311,7 @@ public sealed class JellyfinVirtualPlaylistProtocolAdapter(
var labelProvider = playlist.ProjectionMode == PlaylistProjectionMode.Source
? track.SourceProviderId
: track.RouteProviderId ?? track.SourceProviderId;
AddSourceLabels(item, labelProvider);
AddSourceLabels(item, track, labelProvider);
}
if (playlist.ProjectionMode == PlaylistProjectionMode.Source)
{
@@ -442,9 +443,10 @@ public sealed class JellyfinVirtualPlaylistProtocolAdapter(
.ToDictionary(item => item["Id"]!.GetValue<string>(), StringComparer.Ordinal);
}
private static void AddSourceLabels(JsonObject item, string? provider)
private static void AddSourceLabels(JsonObject item, VirtualPlaylistTrack track, string? provider)
{
foreach (var name in new[] { "Name", "Album", "AlbumArtist" })
item["Name"] = ExternalTrackPresentation.Title(track.Title, track.SourceMetadata?.IsExplicit == true);
foreach (var name in new[] { "Album", "AlbumArtist" })
Label(item, name, provider);
if (item["Artists"] is JsonArray artists)
for (var index = 0; index < artists.Count; index++)
+2 -2
View File
@@ -42,11 +42,11 @@ The Subsonic surface accepts normal query and form-post request styles and prese
## External song labels and playback sources
Injected song titles use `[A]`, or `[A]/[E]` when explicit. Native library titles stay unchanged. Existing external IDs remain compatible; the provider embedded in an ID identifies its catalog entry, not necessarily the provider serving its audio. Album, artist, and playlist relationships remain catalog-specific.
Injected song titles use `[A]`, or `[A]/[E]` when explicit, including playlist source views, unresolved rows, and missing-local-item fallbacks. These labels do not imply that an unresolved row is playable. Native library titles stay unchanged. Existing external IDs remain compatible; the provider embedded in an ID identifies its catalog entry, not necessarily the provider serving its audio. Album, artist, and playlist relationships remain catalog-specific. Clients retaining an older playlist or queue response may need to reload it after an upgrade; a playlist reimport is not required for the title change.
For clients with a device identifier, playback first checks authorized cached copies, then tries configured streaming providers with an exact verified identity for the same recording. Manual source pins remain authoritative. Failed leases, transport failures, unavailable media, empty streams, and non-audio error pages can advance to another eligible source before audio starts. Authentication and policy denials do not bypass authorization, and an active response is never spliced together from multiple providers. A track without verified alternatives stays on its known source; playback does not run a speculative title search.
The Home page distinguishes **Playing from**, **Cached from**, and an unconfirmed catalog source. Jellyfin song details can include the last opened source for that user/device, with its timestamp; whether a client displays `Overview` or the media-source name depends on the client. Stream responses also expose `X-Allstarr-Provider`, never account identifiers or signed URLs. Artwork is deliberately not stamped: cached/shared covers cannot reliably represent a per-listener playback route.
The administrator Home page distinguishes **Playing from**, **Cached from**, and an unconfirmed catalog source. Jellyfin song details can include the last opened source for that user/device, with its timestamp; whether a client displays `Overview` or the media-source name depends on the client. The client must request song details again after the stream opens to receive this observation; metadata fetched before playback cannot name a confirmed source. Musiver's in-player display of this information is not yet qualified. Stream responses also expose `X-Allstarr-Provider`, never account identifiers or signed URLs. Artwork is deliberately not stamped: cached/shared covers cannot reliably represent a per-listener playback route.
Byte-range continuation stays on the opened provider, identity, account, and requested quality. If the short-lived selection is unavailable (for example after a restart), the client must restart playback before seeking. Clients without a device identifier—including standard Subsonic requests that identify only the application name—retain exact-provider playback and seeking; they do not participate in cross-provider failover yet. HEAD probes do not change the reported playback source.
+3 -1
View File
@@ -55,7 +55,9 @@ Evolve the existing [identity service](../allstarr/Core/Matching/TrackIdentitySe
| 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. Prefer a clear External label or accessible icon to a bare `[E]`, which could be read as explicit-content metadata. Local backend titles and objects must remain unchanged.
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) migrate external identities and presentation only when aliases and client compatibility are proved. If identity migration cannot be qualified, retain current IDs for the first release rather than announcing provider-neutral catalog identity prematurely.
+2
View File
@@ -106,6 +106,8 @@ Imports stay private inside Allstarr unless a separate listening-app or scrobbli
3. Choose **Import once**, **Update when I ask**, or a schedule. An imported-once playlist keeps its published snapshot and never reads later source changes, even if the source account is subsequently disabled.
4. Choose **Stream when played** or **Keep every song**. Keep-all queues owner- and library-scoped durable downloads for every resolved external song; local songs are already permanent. An unavailable or unresolved song is reported in Activity without rolling back the playlist import.
5. Open **Mappings** for ambiguous or unresolved tracks.
The default **Mapped** view always preserves the source playlist's full order. Local matches play from the media server, external `[A]` tracks use their eligible mapped providers in the configured streaming order, and unresolved songs remain visible with a clear not-playable status. **Original** previews the source metadata, while **Native** is only a diagnostic view of the separate playlist written into Jellyfin or Subsonic; it can omit external and unresolved entries that the backend cannot store natively.
6. Accept only a candidate that represents the same recording. Use interactive search when automatic candidates are wrong.
An accepted match is reusable across playlist sync, search, playback, and later rematches. A matched local item is returned as the complete native backend object. A genuinely external item keeps a stable virtual identity and provider label.
+2 -1
View File
@@ -74,7 +74,8 @@ This catches clients such as Musiver that open the original Jellyfin playlist
ID instead of an `allstarr-vpl-*` ID. The kit requires one browse row with the
expected count, replays the observed playlist-items query shape, validates
every item ID and playlist context, and runs the existing full native-object
parity checks against matched entries:
parity checks against matched entries. Unresolved playlist rows must also use
the neutral injection label while remaining non-playable:
```bash
INJECTED_PLAYLIST_ID=ddc3db277be524ad6f54e4b276cc619a \
+4 -2
View File
@@ -1123,9 +1123,11 @@ item_contract='
def client_item:
(.Id | nonempty) and (.Name | nonempty) and (.Type | nonempty) and
named_ids and album_ids and genre_ids and media_ids and user_data;
def injected_title:
type == "string" and test(" \\[A\\](/\\[E\\])?$");
def external_audio:
client_item and .Type == "Audio" and .MediaType == "Audio" and
(.Name | type == "string" and test(" \\[A\\](/\\[E\\])?$")) and
(.Name | injected_title) and
(.Album | type == "string") and
((.Album | length) == 0 or (.Album | provider_labeled)) and
(.AlbumId == null or (.AlbumId | nonempty)) and
@@ -1990,7 +1992,7 @@ if [[ -n "$virtual_playlist_id" ]]; then
(.Album | nonempty) and
(.Artists | type == \"array\" and length > 0 and all(.[]; nonempty)) and
(if (.Id | startswith(\"allstarr-unresolved-\"))
then .PlayAccess == \"None\" and .CanDownload == false and
then (.Name | injected_title) and .PlayAccess == \"None\" and .CanDownload == false and
((.MediaSources // []) | length == 0)
elif (.Id | startswith(\"ext-\"))
then external_audio
+4 -1
View File
@@ -106,13 +106,16 @@ class JellyfinSmokeTests(unittest.TestCase):
}],
}
for title, expected in (("Fixture [A]", True), ("Fixture [A]/[E]", True),
("Fixture [Qobuz]", False), ("Fixture [A] [E]", False),
("Fixture [S]", False), ("Fixture [Qobuz]", False), ("Fixture [A] [E]", False),
("Fixture", False)):
with self.subTest(title=title):
audio["Name"] = title
result = subprocess.run(["jq", "-e", contract + "external_audio"],
input=json.dumps(audio), text=True, capture_output=True)
self.assertEqual(result.returncode == 0, expected, result.stderr)
result = subprocess.run(["jq", "-e", contract + "injected_title"],
input=json.dumps(title), text=True, capture_output=True)
self.assertEqual(result.returncode == 0, expected, result.stderr)
def stream(self, path):
with tempfile.TemporaryDirectory() as directory:
+72 -64
View File
@@ -418,13 +418,13 @@
transform: translateX(-0.25rem);
}
.app-shell.slim .sidebar nav a {
.app-shell.slim .desktop-navigation a {
width: var(--control-lg);
grid-template-columns: var(--control-lg) 0fr;
gap: 0;
}
.app-shell.slim .sidebar nav {
.app-shell.slim .desktop-navigation {
width: var(--control-lg);
}
@@ -482,7 +482,7 @@
font-size: var(--text-xs);
}
.sidebar nav {
.desktop-navigation {
display: grid;
gap: 0.35rem;
margin-top: 2rem;
@@ -492,7 +492,7 @@
display: none;
}
.sidebar nav a {
.desktop-navigation a {
position: relative;
display: grid;
width: 100%;
@@ -505,12 +505,12 @@
text-decoration: none;
}
.sidebar nav a:not(.active):hover {
.desktop-navigation a:not(.active):hover {
background: var(--color-panel-raised);
color: var(--color-ink);
}
.sidebar nav a.active {
.desktop-navigation a.active {
background: var(--color-signal-muted);
color: var(--md-sys-color-on-primary-container);
}
@@ -1495,12 +1495,6 @@
margin: 0;
}
.app-shell.slim .mobile-navigation {
width: 100%;
justify-items: stretch;
margin: 0;
}
.mobile-navigation :is(a, button) {
display: flex;
min-width: 0;
@@ -1519,13 +1513,6 @@
text-decoration: none;
}
.app-shell.slim .mobile-navigation :is(a, button) {
width: 100%;
min-height: 3.75rem;
padding: var(--space-1);
font-size: 0.68rem;
}
.mobile-navigation :is(a, button).active {
background: transparent;
color: var(--md-sys-color-on-primary-container);
@@ -2300,6 +2287,8 @@
.playlist-view-switcher > span {
display: grid;
min-width: 0;
flex: 1;
gap: 0.15rem;
}
@@ -2313,7 +2302,8 @@
}
.playlist-view-switcher nav {
width: min(100%, 24rem);
width: min(100%, 20rem);
flex-shrink: 0;
}
.playlist-detail-coverage .coverage-bar {
@@ -2553,7 +2543,7 @@
}
.track-route-column {
width: 9rem;
width: 12rem;
}
.track-duration-column {
@@ -2584,11 +2574,14 @@
text-align: left;
}
.track-data-table thead th:first-child,
.track-data-table thead th:last-child {
.track-data-table .track-index {
text-align: center;
}
.track-data-table .track-duration {
text-align: right;
}
.track-data-table tbody tr {
height: var(--data-row-height);
transition: background 140ms ease;
@@ -2612,8 +2605,7 @@
text-align: center;
}
.track-identity,
.route-cell {
.track-identity {
display: flex;
min-width: 0;
align-items: center;
@@ -2675,26 +2667,6 @@
font-weight: 750;
}
.track-eligibility {
overflow: hidden;
min-width: 0;
flex: 1 1 8rem;
max-width: 12rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
color: var(--color-warning);
padding: 0.15rem 0.4rem;
font-size: 0.65rem;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-eligibility.included {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
}
.playlist-projection-empty {
min-height: 12rem;
margin: 1rem;
@@ -2717,6 +2689,7 @@
.track-menu {
position: relative;
z-index: var(--layer-control);
text-align: center;
}
.track-art {
@@ -2768,28 +2741,63 @@
opacity: var(--disabled-opacity);
}
.route-cell i {
width: 0.48rem;
height: 0.48rem;
flex: none;
border-radius: 50%;
background: var(--route-color);
}
.route-cell strong {
overflow: hidden;
font-size: var(--text-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.track-menu {
.route-summary {
display: grid;
place-items: center;
min-width: 0;
gap: 0.1rem;
font-size: var(--text-xs);
text-align: left;
}
.route-summary small,
.track-routes-panel :is(p, small) {
color: var(--color-ink-muted);
font-size: var(--text-xs);
}
.track-routes-trigger {
display: flex;
width: 100%;
min-height: var(--control-md);
align-items: center;
justify-content: space-between;
gap: var(--space-2);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-ink);
cursor: pointer;
padding: 0;
}
.track-routes-trigger:hover {
background: var(--color-panel-raised);
}
.track-routes-panel {
width: min(22rem, calc(100vw - 2rem));
padding: var(--space-4);
}
.track-routes-panel ul {
list-style: none;
margin: var(--space-3) 0;
padding: 0;
}
.track-routes-panel li {
display: flex;
align-items: center;
gap: var(--space-2);
padding-block: var(--space-2);
}
.track-routes-panel li small {
display: block;
}
.track-menu-trigger {
display: grid;
display: inline-grid;
width: var(--control-md);
height: var(--control-md);
border: 1px solid transparent;
@@ -3063,7 +3071,7 @@
.segmented-tabs {
position: relative;
display: grid;
grid-template-columns: repeat(var(--tab-count), minmax(0, 1fr));
grid-template-columns: repeat(var(--tab-count), minmax(max-content, 1fr));
max-width: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
+16 -26
View File
@@ -20,6 +20,7 @@
import SearchField from "$lib/components/SearchField.svelte";
import SegmentedNav from "$lib/components/SegmentedNav.svelte";
import SelectField from "$lib/components/SelectField.svelte";
import TrackRoutes from "$lib/components/TrackRoutes.svelte";
import {
home,
matchReview,
@@ -39,7 +40,6 @@
isReviewTrack,
playlistOutcomeLabel,
playlistProjectionOptions,
providerColor,
runBounded,
scheduleCadence,
type PlaylistSort,
@@ -121,10 +121,11 @@
);
const projectedTracks = $derived.by((): PlaylistTrack[] => {
if (!details?.clientProjection) return [];
const sourceTracks = new Map(details.tracks.map((track) => [track.sourcePosition, track]));
return details.clientProjection.tracks.map((track) => {
const source = viewMode === "target"
? undefined
: details?.tracks.find((item) => item.sourcePosition === track.sourcePosition);
: sourceTracks.get(track.sourcePosition);
return {
sourcePosition: track.sourcePosition,
position: track.position,
@@ -159,7 +160,10 @@
detailProjectionOptions.find((option) => option.id === viewMode)?.label ?? "Selected songs",
);
const savedProjectionLabel = $derived(
detailProjectionOptions.find((option) => option.id === selected?.projectionMode)?.label ?? "Media server when available",
detailProjectionOptions.find((option) => option.id === selected?.projectionMode)?.label ?? "Mapped",
);
const selectedProjectionDescription = $derived(
detailProjectionOptions.find((option) => option.id === viewMode)?.description,
);
const visibleTrackColumnCount = $derived(
trackColumnOptions.filter((column) => trackColumns[column.id]).length,
@@ -169,7 +173,7 @@
(trackColumns.position ? 3 : 0) +
(trackColumns.artist ? 12 : 0) +
(trackColumns.album ? 14 : 0) +
(trackColumns.route ? 9 : 0) +
(trackColumns.route ? 12 : 0) +
(trackColumns.duration ? 4.5 : 0),
);
@@ -616,8 +620,7 @@
<ProviderMark id={details.sourceProviderId} definition={provider(details.sourceProviderId)} />
<span>{providerName(details.sourceProviderId)}</span>
<ArrowRight size={18} aria-hidden="true" />
<ProviderMark id={details.targetProtocol} definition={provider(details.targetProtocol)} />
<span>{providerName(details.targetProtocol)}</span>
<span>Allstarr · via {providerName(details.targetProtocol)}</span>
</div>
</div>
<Dialog.Close class="icon-button playlist-dialog-close" aria-label="Close playlist details"><X size={18} aria-hidden="true" /></Dialog.Close>
@@ -644,12 +647,12 @@
</header>
<div class="playlist-view-switcher">
<span><strong>What listeners see</strong><small>Preview only. Saved choice: {savedProjectionLabel}.</small></span>
<span><strong>Playlist view · {savedProjectionLabel} for listeners</strong><small>{selectedProjectionDescription}</small><small>Previewing a tab does not change the saved view.</small></span>
<SegmentedNav
items={detailProjectionOptions}
active={viewMode}
label="What listeners see"
class="route-tabs contextual-tabs"
label="Playlist preview"
class="contextual-tabs"
onchange={(mode) => void changeView(mode)}
/>
</div>
@@ -808,12 +811,12 @@
</colgroup>
<thead>
<tr>
{#if trackColumns.position}<th scope="col">#</th>{/if}
{#if trackColumns.position}<th scope="col" class="track-index">#</th>{/if}
<th scope="col">Track</th>
{#if trackColumns.artist}<th scope="col">Artist</th>{/if}
{#if trackColumns.album}<th scope="col">Album</th>{/if}
{#if trackColumns.route}<th scope="col">Route</th>{/if}
{#if trackColumns.duration}<th scope="col">Time</th>{/if}
{#if trackColumns.route}<th scope="col">Playback routes</th>{/if}
{#if trackColumns.duration}<th scope="col" class="track-duration">Time</th>{/if}
<th scope="col"><span class="sr-only">Details</span></th>
</tr>
</thead>
@@ -844,13 +847,6 @@
{#if isReviewTrack(track)}
<small class="track-review-badge">Needs review</small>
{/if}
{#if viewMode !== "target" && track.outcomeCode}
<small
class="track-eligibility"
class:included={track.targetEligible}
title={playlistOutcomeLabel(track.outcomeCode, `${providerName(details.targetProtocol)} playlist`)}
>{playlistOutcomeLabel(track.outcomeCode, `${providerName(details.targetProtocol)} playlist`)}</small>
{/if}
</span>
</th>
{#if trackColumns.artist}
@@ -861,13 +857,7 @@
{/if}
{#if trackColumns.route}
<td>
<span class="route-cell">
<i style={`--route-color:${providerColor(track.routeProviderId ?? track.routeKind)}`}></i>
<span>
<strong>{providerName(track.routeProviderId ?? (track.routeKind === "local" ? details.targetProtocol : null))}</strong>
<small>{track.routeKind}</small>
</span>
</span>
<TrackRoutes {track} {providers} backend={details.targetProtocol} />
</td>
{/if}
{#if trackColumns.duration}
@@ -0,0 +1,55 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "$lib/components/ui/popover";
import ProviderMark from "$lib/components/ProviderMark.svelte";
import type { PlaylistTrack, ProviderDefinition } from "$lib/api";
import { findProviderDefinition, providerDisplayName } from "$lib/sources";
let { track, providers, backend }: {
track: PlaylistTrack; providers: ProviderDefinition[]; backend: string;
} = $props();
const local = $derived(track.routeKind === "local");
const routes = $derived([...new Set([
...(track.routeProviderId ? [track.routeProviderId] : []),
...track.providerRoutes.map((route) => route.providerId),
])]);
const name = (id: string) => providerDisplayName(providers, id, id);
</script>
{#snippet summary()}
<span class="route-summary">
<strong>{local ? name(backend) : "External [A]"}</strong>
<small>{local ? "Local library" : `${routes.length} mapped ${routes.length === 1 ? "provider" : "providers"}`}</small>
</span>
{/snippet}
{#if track.routeKind === "unmatched" || track.routeKind === "unresolved"}
<span class="route-summary"><strong>Unresolved</strong><small>Not playable</small></span>
{:else if routes.length === 0}
{@render summary()}
{:else}
<Popover.Root>
<Popover.Trigger class="track-routes-trigger" aria-label={`Routes for ${track.title}`}>
{@render summary()}
<ChevronDown size={14} aria-hidden="true" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content class="bits-menu track-routes-panel" sideOffset={6} align="end">
<strong>{local ? "Local library + external mappings" : "Mapped providers"}</strong>
<ul aria-label={`Mapped providers for ${track.title}`}>
{#if local}
<li><ProviderMark id={backend} definition={findProviderDefinition(providers, backend)} /><span>{name(backend)}<small>Local · used by this playlist row</small></span></li>
{/if}
{#each routes as id}
<li>
<ProviderMark {id} definition={findProviderDefinition(providers, id)} />
<span>{name(id)}<small>{track.providerRoutes.some((route) => route.providerId === id && route.pinned) ? "Manual pin" : "Verified mapping"}</small></span>
</li>
{/each}
</ul>
<p>For external [A] songs, playback uses eligible mappings in your streaming order. An authorized cached copy may play first; manual pins and account access still apply.</p>
<small>Mappings are not a live availability check. The provider actually serving a song is reported during playback.</small>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
{/if}
+4 -1
View File
@@ -126,8 +126,11 @@ describe("playlist presentation", () => {
expect(destinations[0].description).toContain("will not create or change");
expect(destinations[1].description).toContain("playable Morning mix songs");
expect(views.map((item) => item.label)).toEqual([
"Jellyfin when available", "Every song from Spotify", "Road trip in Jellyfin",
"Mapped", "Original", "Native",
]);
expect(views[0].description).toContain("Every Spotify song, in order");
expect(views[0].description).toContain("Unresolved songs stay visible");
expect(views[2].description).toContain("External and unresolved songs are not added");
expect(playlistBehaviorSummary(
"materialized", "reconcile", "Morning mix", "Jellyfin", "Road trip",
)).toContain("only when you run an update");
+4 -7
View File
@@ -21,13 +21,10 @@ export function playlistProjectionOptions(
targetName = "your media server",
playlistName = `the selected ${targetName} playlist`,
) {
const targetLabel = playlistName.startsWith("the selected ")
? `${targetName} playlist`
: `${playlistName} in ${targetName}`;
return [
{ id: "resolved", label: `${targetName} when available`, description: `Listeners get songs from ${targetName} when available and the original ${sourceName} version for anything else.` },
{ id: "source", label: `Every song from ${sourceName}`, description: `Keep the songs and order from ${sourceName}, even when a song is not in ${targetName}.` },
{ id: "target", label: targetLabel, description: `Show exactly the songs currently in ${playlistName}.` },
{ id: "resolved", label: "Mapped", description: `Every ${sourceName} song, in order, using local ${targetName} matches or mapped external providers. Unresolved songs stay visible but cannot play.` },
{ id: "source", label: "Original", description: `The same full playlist with original ${sourceName} titles and artwork. Playback still uses available mappings; unresolved songs cannot play.` },
{ id: "target", label: "Native", description: `Only the songs currently in ${playlistName}. External and unresolved songs are not added to a native ${targetName} playlist.` },
] as const;
}
@@ -143,7 +140,7 @@ export function filterTracks(
(track) =>
(route === "all" || (route === "review" ? isReviewTrack(track) : track.routeKind === route)) &&
(!needle ||
`${track.title} ${track.artists.join(" ")} ${track.album ?? ""} ${track.routeProviderId ?? ""}`
`${track.title} ${track.artists.join(" ")} ${track.album ?? ""} ${track.routeProviderId ?? ""} ${track.providerRoutes.map((item) => item.providerId).join(" ")}`
.toLocaleLowerCase()
.includes(needle)),
)
+14 -3
View File
@@ -51,6 +51,8 @@
let OnboardingDialog = $state<Component<any>>();
let sidebarSlim = $state(false);
let mobileMenuOpen = $state(false);
let mobileMenuTrigger = $state<HTMLButtonElement>();
let sidebarExpander = $state<HTMLButtonElement>();
let themeMode = $state<ThemeMode>("system");
let ActiveView = $state<Component<any>>();
let loadedRoute = $state("");
@@ -240,20 +242,24 @@
onMount(() => {
const compactSidebar = matchMedia("(min-width: 761px) and (max-width: 900px)");
const mobileNavigation = matchMedia("(max-width: 760px)");
const colorScheme = matchMedia("(prefers-color-scheme: dark)");
themeMode = readThemeMode();
const applySidebarBreakpoint = () => { sidebarSlim = compactSidebar.matches; };
const closeDesktopMenu = () => { if (!mobileNavigation.matches) mobileMenuOpen = false; };
const applySystemTheme = () => { if (themeMode === "system") applyThemeMode(themeMode); };
const unsubscribeTheme = onThemeModeChange((mode) => { themeMode = mode; });
applySidebarBreakpoint();
applyThemeMode(themeMode);
compactSidebar.addEventListener("change", applySidebarBreakpoint);
mobileNavigation.addEventListener("change", closeDesktopMenu);
colorScheme.addEventListener("change", applySystemTheme);
void bootstrap();
return () => {
compactSidebar.removeEventListener("change", applySidebarBreakpoint);
mobileNavigation.removeEventListener("change", closeDesktopMenu);
colorScheme.removeEventListener("change", applySystemTheme);
unsubscribeTheme();
liveUpdates.close();
@@ -373,6 +379,7 @@
</a>
<button
class="sidebar-expander"
bind:this={sidebarExpander}
type="button"
aria-label={sidebarSlim ? "Expand sidebar" : "Collapse sidebar"}
aria-expanded={!sidebarSlim}
@@ -410,6 +417,7 @@
type="button"
class:active={moreDestinationActive}
aria-label="More destinations"
bind:this={mobileMenuTrigger}
aria-pressed={moreDestinationActive}
aria-haspopup="dialog"
aria-expanded={mobileMenuOpen}
@@ -421,7 +429,7 @@
</nav>
<div class="profile">
<a class="avatar" href="#/settings" aria-label={`Settings for ${session.user?.name ?? "current user"}`}>
<span class="avatar" aria-hidden="true">
{#if session.user?.avatarUrl && !avatarFailed}
<img
src={session.user.avatarUrl}
@@ -433,7 +441,7 @@
{:else}
<span>{initials}</span>
{/if}
</a>
</span>
<div class="min-w-0">
<strong>{session.user?.name}</strong>
<small>{session.backend}</small>
@@ -526,7 +534,10 @@
<Dialog.Root bind:open={mobileMenuOpen}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="source-dialog mobile-menu-sheet">
<Dialog.Content class="source-dialog mobile-menu-sheet" onCloseAutoFocus={(event) => {
event.preventDefault();
(mobileMenuTrigger?.offsetParent ? mobileMenuTrigger : sidebarExpander)?.focus();
}}>
<header>
<div>
<p class="eyebrow">Account and administration</p>
+72 -13
View File
@@ -948,8 +948,8 @@ for (const viewport of viewports) {
await page.goto("#/library/playlists");
await page.getByRole("button", { name: "Open Test playlist playlist details" }).click();
const details = page.getByRole("dialog", { name: "Test playlist" });
await expect(details).not.toContainText(/native playlist|hybrid|materialized|write-back|projection mode|backend/i);
await details.getByRole("tab", { name: "Jellyfin playlist", exact: true }).click();
await expect(details).not.toContainText(/hybrid|materialized|write-back|projection mode/i);
await details.getByRole("tab", { name: "Native", exact: true }).click();
await expect(details.getByText("target track", { exact: true })).toBeVisible();
await expect(details).toHaveCSS("overflow", "hidden");
await expect(details.locator(".track-scroll")).toHaveCSS("overflow", "auto");
@@ -2087,7 +2087,7 @@ test("Slim sidebar centers navigation and profile controls", async ({ page }) =>
const sidebar = await page.locator(".sidebar").boundingBox();
const home = await page.locator(".desktop-navigation").getByRole("link", { name: "Home", exact: true }).boundingBox();
const sourcesIcon = await page.locator(".desktop-navigation").getByRole("link", { name: "Integrations" }).locator("svg").boundingBox();
const profile = await page.getByRole("link", { name: "Settings for Tester" }).boundingBox();
const profile = await page.locator(".profile .avatar").boundingBox();
expect(sidebar && home && sourcesIcon && profile).toBeTruthy();
const center = (box: NonNullable<typeof sidebar>) => box.x + box.width / 2;
expect(Math.abs(center(home!) - center(sidebar!))).toBeLessThanOrEqual(1);
@@ -2406,7 +2406,7 @@ test("Import playlist separates source, client view, destination, updates, and s
await expect(dialog.getByRole("button", { name: "What listeners see", exact: true })).toHaveAttribute("aria-current", "step");
if (process.env.ALLSTARR_SCREENSHOT_DIR)
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/playlist-390-listener-choice.png` });
await dialog.getByRole("radio", { name: /Every song from Spotify Keep the songs/ }).check();
await dialog.getByRole("radio", { name: /Original The same full playlist/ }).check();
await dialog.getByRole("button", { name: "Continue" }).click();
await expect.poll(() => dialog.locator(".playlist-add-body").evaluate((body) => body.scrollTop)).toBe(0);
await dialog.getByRole("radio", { name: /Show through Allstarr and add songs to.*Allstarr will show/ }).check();
@@ -2470,13 +2470,13 @@ test("Playlist views and revisioned settings stay keyboard-safe", async ({ page
await page.getByRole("button", { name: "Open Test playlist playlist details" }).click();
const details = page.getByRole("dialog", { name: "Test playlist" });
await expect(details).toBeVisible();
const resolved = details.getByRole("tab", { name: "Jellyfin when available", exact: true });
const resolved = details.getByRole("tab", { name: "Mapped", exact: true });
await resolved.focus();
await page.keyboard.press("ArrowRight");
await expect(details.getByRole("tab", { name: "Every song from Lumen Audio", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(details.getByRole("tab", { name: "Original", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(details.getByText("source track", { exact: true })).toBeVisible();
await page.keyboard.press("ArrowRight");
await expect(details.getByRole("tab", { name: "Jellyfin playlist", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(details.getByRole("tab", { name: "Native", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(details.getByText("target track", { exact: true })).toBeVisible();
await expect(details).toHaveCSS("overflow", "hidden");
await expect(details.locator(".track-scroll")).toHaveCSS("overflow", "auto");
@@ -2487,7 +2487,7 @@ test("Playlist views and revisioned settings stay keyboard-safe", async ({ page
await page.getByRole("menuitem", { name: "Edit settings" }).click();
let settings = page.getByRole("dialog", { name: "Edit playlist settings" });
await exactTarget;
await settings.getByRole("radio", { name: /Every song from Lumen Audio Keep the songs/ }).check();
await settings.getByRole("radio", { name: /Original The same full playlist/ }).check();
await settings.getByRole("radio", { name: /Show only through Allstarr Allstarr will show/ }).check();
const update = page.waitForRequest((request) =>
request.method() === "PUT" && request.url().endsWith("/api/admin/playlist-links/playlist-link"));
@@ -2530,7 +2530,7 @@ test("Playlist views and revisioned settings stay keyboard-safe", async ({ page
contentType: "application/json",
body: JSON.stringify({ error: "Projection fixture unavailable" }),
}), { times: 1 });
await details.getByRole("tab", { name: "Lumen Audio" }).click();
await details.getByRole("tab", { name: "Original" }).click();
const projectionError = details.getByRole("alert");
await expect(projectionError).toContainText("Projection fixture unavailable");
await projectionError.getByRole("button", { name: "Try again" }).click();
@@ -3316,6 +3316,65 @@ test("Mobile primary navigation stays contained and moves secondary tasks into M
}
});
for (const theme of ["light", "dark"] as const) {
test(`Navigation has one visible primary menu across breakpoints in ${theme}`, async ({ page }) => {
await page.addInitScript((mode) => localStorage.setItem("allstarr.theme", mode), theme);
await page.emulateMedia({ reducedMotion: theme === "dark" ? "reduce" : "no-preference" });
await mockApi(page);
await page.goto("#/library/playlists");
for (const width of [1606, 901, 900, 761, 760, 390, 320]) {
await page.setViewportSize({ width, height: 844 });
const primary = page.getByRole("navigation", { name: "Primary", exact: true });
await expect(primary).toHaveCount(1);
for (const name of ["Home", "Library", "Activity"])
await expect(primary.getByRole("link", { name, exact: true })).toHaveCount(1);
await expect(primary.getByRole("link", { name: "Library", exact: true }))
.toHaveAttribute("aria-current", "page");
await expect(page.locator(".profile a[href='#/settings']")).toHaveCount(0);
if (width > 760) {
await expect(page.locator(".mobile-navigation")).toBeHidden();
await expect(primary.getByRole("link")).toHaveCount(5);
await expect(primary.getByRole("link", { name: "Settings", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "More destinations" })).toHaveCount(0);
} else {
await expect(page.locator(".desktop-navigation")).toBeHidden();
await expect(primary.getByRole("link")).toHaveCount(3);
await expect(primary.getByRole("button", { name: "More destinations" })).toBeVisible();
}
if (process.env.ALLSTARR_SCREENSHOT_DIR && [1606, 900, 390].includes(width))
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/${theme}-navigation-${width}.png` });
}
});
}
test("More restores keyboard focus and closes when returning to desktop", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockApi(page);
await page.goto("#/library/playlists");
const more = page.getByRole("button", { name: "More destinations" });
await more.focus();
await more.press("Enter");
const sheet = page.getByRole("dialog", { name: "More", exact: true });
await expect(sheet).toBeVisible();
await expect(page.locator(".mobile-navigation button")).toHaveAttribute("aria-expanded", "true");
await expect(sheet.getByRole("link")).toHaveCount(2);
await page.keyboard.press("Escape");
await expect(sheet).toBeHidden();
await expect(more).toBeFocused();
await more.press("Enter");
await expect(sheet).toBeVisible();
await page.setViewportSize({ width: 1280, height: 800 });
await expect(sheet).toBeHidden();
await expect(page.getByRole("button", { name: "Collapse sidebar" })).toBeFocused();
await expect(page.getByRole("navigation", { name: "Primary", exact: true })).toHaveCount(1);
await page.getByRole("link", { name: "Settings", exact: true }).click();
await expect(page).toHaveURL(/#\/settings$/);
await page.setViewportSize({ width: 390, height: 844 });
await expect(sheet).toBeHidden();
await more.click();
await expect(sheet.getByRole("link", { name: /Settings/ })).toHaveAttribute("aria-current", "page");
});
test("Contextual dialog tabs scroll inside their panels at narrow widths", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 844 });
const screenshotTheme = process.env.ALLSTARR_SCREENSHOT_THEME;
@@ -4727,12 +4786,12 @@ test("playlist projections, operations, and cancellation keep the selected playl
await projection.promise;
return route.fallback();
});
await details.getByRole("tab", { name: "Every song from Lumen Audio" }).click();
await details.getByRole("tab", { name: "Jellyfin playlist" }).click();
await expect(details.getByRole("tab", { name: "Jellyfin playlist" })).toHaveAttribute("aria-selected", "true");
await details.getByRole("tab", { name: "Original" }).click();
await details.getByRole("tab", { name: "Native" }).click();
await expect(details.getByRole("tab", { name: "Native" })).toHaveAttribute("aria-selected", "true");
await expect(details.getByText("target track", { exact: true })).toBeVisible();
projection.release();
await expect(details.getByRole("tab", { name: "Jellyfin playlist" })).toHaveAttribute("aria-selected", "true");
await expect(details.getByRole("tab", { name: "Native" })).toHaveAttribute("aria-selected", "true");
await details.getByRole("button", { name: "Actions" }).click();
await page.getByRole("menuitem", { name: "Update playlist now" }).click();