From 751c2878f71675d39cfb76464184ea1f026785c0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 22:33:30 -0400 Subject: [PATCH 001/111] fix(flutter/android): production manifest + AudioServiceActivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related Android setup gaps caught when running on a clean Pixel 6 emulator: 1. MainActivity extended FlutterActivity, but the audio_service plugin requires AudioServiceActivity. AudioService.init() threw PlatformException("The Activity class declared in your AndroidManifest.xml is wrong"), and the partial-engine-reinit that followed cascaded into a flutter_cache_manager SQLite EXCLUSIVE lock crash. Both errors clear with the correct base class. 2. The main manifest was missing two production permissions: - POST_NOTIFICATIONS — Android 13+ runtime requirement for the media notification (now-playing tile in the system tray). audio_service auto-prompts at init() time once declared. - ACCESS_NETWORK_STATE — used by ConnectionErrorBanner + flutter_cache_manager for connectivity awareness. Note for future contributors: src/main/AndroidManifest.xml IS the production manifest. The src/debug and src/profile variants only add dev-only entries (e.g. INTERNET re-declared in debug for hot-reload). There is no separate "release" manifest in Flutter's convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../android/app/src/main/AndroidManifest.xml | 6 ++++++ .../kotlin/com/fabledsword/minstrel/MainActivity.kt | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/flutter_client/android/app/src/main/AndroidManifest.xml b/flutter_client/android/app/src/main/AndroidManifest.xml index f341429d..0cb708d7 100644 --- a/flutter_client/android/app/src/main/AndroidManifest.xml +++ b/flutter_client/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,14 @@ + + + + Date: Fri, 8 May 2026 22:47:25 -0400 Subject: [PATCH 002/111] fix(flutter): cover URLs resolve against server baseUrl + audio stream URL guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover-art Image.network calls were passing server-relative URLs (/api/albums//cover) straight to NetworkImage, which interprets "no scheme" as file:/// and crashes with "No host specified in URI". Same root cause regardless of HTTPS or HTTP server. ServerImage wraps Image.network with a Riverpod read of serverUrlProvider and prefixes the configured base URL. Absolute URLs (e.g. discover screen's Lidarr image_urls) pass through unchanged. Three call sites updated: album_card, artist_card, playlists_list_screen. discover_screen left as-is — its row.imageUrl is already absolute (Lidarr returns full URLs from MusicBrainz / Spotify metadata) and it has a meaningful errorBuilder that ServerImage doesn't expose. Also adds a defensive check in audio_handler.setQueueFromTracks: if the constructed stream URL ends up scheme-less, throw a StateError naming baseUrl + track.streamUrl + track.id instead of letting it fall through to ExoPlayer which surfaces a confusing "Cleartext HTTP traffic to 127.0.0.1 not permitted" error (Android's URL parser defaults a scheme-less URI to localhost). User reported this exact confusing error against an HTTPS prod server; the better message will pinpoint where the empty baseUrl comes from on next reproduction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/library/widgets/album_card.dart | 3 +- .../lib/library/widgets/artist_card.dart | 3 +- flutter_client/lib/player/audio_handler.dart | 32 ++++++++++--- .../lib/playlists/playlists_list_screen.dart | 3 +- .../lib/shared/widgets/server_image.dart | 47 +++++++++++++++++++ 5 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 flutter_client/lib/shared/widgets/server_image.dart diff --git a/flutter_client/lib/library/widgets/album_card.dart b/flutter_client/lib/library/widgets/album_card.dart index 84915b29..7f844ef2 100644 --- a/flutter_client/lib/library/widgets/album_card.dart +++ b/flutter_client/lib/library/widgets/album_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../models/album.dart'; +import '../../shared/widgets/server_image.dart'; import '../../theme/theme_extension.dart'; class AlbumCard extends StatelessWidget { @@ -27,7 +28,7 @@ class AlbumCard extends StatelessWidget { color: fs.slate, child: album.coverUrl.isEmpty ? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover) - : Image.network(album.coverUrl, fit: BoxFit.cover), + : ServerImage(url: album.coverUrl, fit: BoxFit.cover), ), ), const SizedBox(height: 8), diff --git a/flutter_client/lib/library/widgets/artist_card.dart b/flutter_client/lib/library/widgets/artist_card.dart index 8c17b5ef..526dff9f 100644 --- a/flutter_client/lib/library/widgets/artist_card.dart +++ b/flutter_client/lib/library/widgets/artist_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../models/artist.dart'; +import '../../shared/widgets/server_image.dart'; import '../../theme/theme_extension.dart'; class ArtistCard extends StatelessWidget { @@ -26,7 +27,7 @@ class ArtistCard extends StatelessWidget { color: fs.slate, child: artist.coverUrl.isEmpty ? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover) - : Image.network(artist.coverUrl, fit: BoxFit.cover), + : ServerImage(url: artist.coverUrl, fit: BoxFit.cover), ), ), const SizedBox(height: 8), diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 3e0a86ef..657e5b4c 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -26,12 +26,21 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl final headers = _token == null ? null : {'Authorization': 'Bearer $_token'}; final sources = tracks.map((t) { - final url = t.streamUrl.isNotEmpty - ? (Uri.tryParse(t.streamUrl)?.hasScheme == true - ? t.streamUrl - : '$_baseUrl${t.streamUrl}') - : '$_baseUrl/api/tracks/${t.id}/stream'; - return AudioSource.uri(Uri.parse(url), headers: headers); + final url = _resolveStreamUrl(t); + // Defensive: a scheme-less URL handed to ExoPlayer crashes with + // a confusing "Cleartext HTTP traffic to 127.0.0.1 not permitted" + // error because Android's URL parser falls back to localhost. + // Surface the actual URL so debugging is straightforward. + final parsed = Uri.parse(url); + if (!parsed.hasScheme || parsed.host.isEmpty) { + throw StateError( + 'audio_handler: refused to play scheme-less URL "$url" ' + '(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", ' + 'track.id="${t.id}"). configure() must be called with a ' + 'non-empty baseUrl before setQueueFromTracks().', + ); + } + return AudioSource.uri(parsed, headers: headers); }).toList(); await _player.setAudioSources( @@ -40,6 +49,17 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl ); } + String _resolveStreamUrl(TrackRef t) { + if (t.streamUrl.isEmpty) { + return '$_baseUrl/api/tracks/${t.id}/stream'; + } + final parsed = Uri.tryParse(t.streamUrl); + if (parsed != null && parsed.hasScheme) { + return t.streamUrl; + } + return '$_baseUrl${t.streamUrl}'; + } + MediaItem _toMediaItem(TrackRef t) => MediaItem( id: t.id, title: t.title, diff --git a/flutter_client/lib/playlists/playlists_list_screen.dart b/flutter_client/lib/playlists/playlists_list_screen.dart index e34cc962..55614b93 100644 --- a/flutter_client/lib/playlists/playlists_list_screen.dart +++ b/flutter_client/lib/playlists/playlists_list_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../models/playlist.dart'; import '../shared/widgets/main_app_bar_actions.dart'; +import '../shared/widgets/server_image.dart'; import '../theme/theme_extension.dart'; import 'playlists_provider.dart'; @@ -81,7 +82,7 @@ class _PlaylistTile extends StatelessWidget { color: fs.slate, child: playlist.coverUrl.isEmpty ? Icon(Icons.queue_music, color: fs.ash) - : Image.network(playlist.coverUrl, fit: BoxFit.cover), + : ServerImage(url: playlist.coverUrl, fit: BoxFit.cover), ), ), const SizedBox(width: 12), diff --git a/flutter_client/lib/shared/widgets/server_image.dart b/flutter_client/lib/shared/widgets/server_image.dart new file mode 100644 index 00000000..6559ac9d --- /dev/null +++ b/flutter_client/lib/shared/widgets/server_image.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../auth/auth_provider.dart'; + +/// Image.network wrapper that resolves server-relative URLs (e.g. +/// `/api/albums//cover`) against the configured server base URL +/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through +/// unchanged. Empty/null base or empty URL render the [fallback] +/// (defaults to a transparent SizedBox so callers can supply their own +/// surrounding placeholder). +/// +/// Mirrors the absolute-or-relative logic the audio handler uses for +/// stream URLs, so cover art and audio resolve URLs the same way. +class ServerImage extends ConsumerWidget { + const ServerImage({ + super.key, + required this.url, + this.fit, + this.fallback, + }); + + final String url; + final BoxFit? fit; + final Widget? fallback; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final empty = fallback ?? const SizedBox.shrink(); + if (url.isEmpty) return empty; + final base = ref.watch(serverUrlProvider).value; + final resolved = _resolve(base, url); + if (resolved == null) return empty; + return Image.network(resolved, fit: fit); + } + + static String? _resolve(String? baseUrl, String url) { + final parsed = Uri.tryParse(url); + if (parsed != null && parsed.hasScheme) return url; + if (baseUrl == null || baseUrl.isEmpty) return null; + final base = baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; + final path = url.startsWith('/') ? url : '/$url'; + return '$base$path'; + } +} From 0bc17a85fb63948c6db652c74aac44e52f667c5c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 23:07:15 -0400 Subject: [PATCH 003/111] feat(server/admin): test endpoint returns profiles + folders on success Extends POST /api/admin/lidarr/test response to include quality_profiles, metadata_profiles, root_folders, and an optional list_errors map when the Lidarr connection succeeds. The three list fetches run in parallel after the ping; any per-list failure goes into list_errors so the response can still report ok=true with whatever data did come back. Failed-ping response shape is unchanged. This lets /admin/integrations populate its dropdowns on a single round-trip during first-time setup, fixing the chicken-and-egg where the dropdowns previously gated on cfg.Enabled (which can't be true until the first save, which itself needs non-zero defaults). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/admin_lidarr.go | 99 +++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/internal/api/admin_lidarr.go b/internal/api/admin_lidarr.go index 0041214b..7eea6dfc 100644 --- a/internal/api/admin_lidarr.go +++ b/internal/api/admin_lidarr.go @@ -1,10 +1,12 @@ package api import ( + "context" "encoding/json" "errors" "net/http" "net/url" + "sync" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" @@ -131,6 +133,24 @@ type testLidarrBody struct { APIKey string `json:"api_key"` } +// testLidarrResponse is the JSON shape returned by POST /api/admin/lidarr/test. +// All fields except Ok are optional. On a failed connection (Ok=false), only +// Ok and Error are populated. On success (Ok=true), Version is populated; +// QualityProfiles / MetadataProfiles / RootFolders carry the live Lidarr +// data and ListErrors maps any per-list fetch failures by list name. The +// integrations page consumes the lists to pre-fill its dropdowns on +// first-time setup, fixing the chicken-and-egg where the dropdowns +// previously gated on cfg.Enabled. +type testLidarrResponse struct { + Ok bool `json:"ok"` + Version string `json:"version,omitempty"` + Error string `json:"error,omitempty"` + QualityProfiles []qualityProfileView `json:"quality_profiles,omitempty"` + MetadataProfiles []metadataProfileView `json:"metadata_profiles,omitempty"` + RootFolders []rootFolderView `json:"root_folders,omitempty"` + ListErrors map[string]string `json:"list_errors,omitempty"` +} + // handleTestLidarrConnection implements POST /api/admin/lidarr/test. // Always returns 200; the ok/error fields in the response body indicate // connection success or failure. @@ -169,10 +189,85 @@ func (h *handlers) handleTestLidarrConnection(w http.ResponseWriter, r *http.Req // must NEVER be logged. h.logger.Warn("admin: test lidarr ping failed", "err", err, "base_url", baseURL, "code", errCode) - writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": errCode}) + writeJSON(w, http.StatusOK, testLidarrResponse{Ok: false, Error: errCode}) return } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": result.Version}) + quality, metadata, folders, listErrs := fetchLidarrLists(r.Context(), client) + writeJSON(w, http.StatusOK, testLidarrResponse{ + Ok: true, + Version: result.Version, + QualityProfiles: quality, + MetadataProfiles: metadata, + RootFolders: folders, + ListErrors: listErrs, + }) +} + +// fetchLidarrLists fans out the three list calls in parallel and returns +// the lists plus a map of any per-list errors. Empty map (returned as nil +// to omit the JSON field) if all succeeded. Used by the test handler to +// pre-populate the integrations page dropdowns on the same round-trip as +// the connection check. +func fetchLidarrLists(ctx context.Context, client *lidarr.Client) ( + []qualityProfileView, []metadataProfileView, []rootFolderView, map[string]string, +) { + var ( + quality []qualityProfileView + metadata []metadataProfileView + folders []rootFolderView + errMu sync.Mutex + errs = map[string]string{} + wg sync.WaitGroup + ) + recordErr := func(name string, err error) { + errMu.Lock() + errs[name] = lidarrErrCode(err) + errMu.Unlock() + } + + wg.Add(3) + go func() { + defer wg.Done() + ps, err := client.ListQualityProfiles(ctx) + if err != nil { + recordErr("quality_profiles", err) + return + } + quality = make([]qualityProfileView, len(ps)) + for i, p := range ps { + quality[i] = qualityProfileView{ID: p.ID, Name: p.Name} + } + }() + go func() { + defer wg.Done() + ps, err := client.ListMetadataProfiles(ctx) + if err != nil { + recordErr("metadata_profiles", err) + return + } + metadata = make([]metadataProfileView, len(ps)) + for i, p := range ps { + metadata[i] = metadataProfileView{ID: p.ID, Name: p.Name} + } + }() + go func() { + defer wg.Done() + fs, err := client.ListRootFolders(ctx) + if err != nil { + recordErr("root_folders", err) + return + } + folders = make([]rootFolderView, len(fs)) + for i, f := range fs { + folders[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace} + } + }() + wg.Wait() + + if len(errs) == 0 { + return quality, metadata, folders, nil + } + return quality, metadata, folders, errs } // lidarrErrCode maps a lidarr client error to the stable string code used in From 22f03a5fe80a62828b915da63d5a1c6cec39ec41 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 23:07:47 -0400 Subject: [PATCH 004/111] test(server/admin): cover new test endpoint response shape - Updates the happy-path test stub to handle the three list endpoints Lidarr exposes (qualityprofile / metadataprofile / rootfolder) and asserts profiles + folders make it through to the response. - Adds a partial-failure case where one list endpoint 5xxs; verifies ok=true, the failing list is omitted, and list_errors carries its bucket code. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/admin_lidarr_test.go | 79 ++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/internal/api/admin_lidarr_test.go b/internal/api/admin_lidarr_test.go index d0e882b5..f5b1618e 100644 --- a/internal/api/admin_lidarr_test.go +++ b/internal/api/admin_lidarr_test.go @@ -216,9 +216,20 @@ func TestHandleTestLidarrConnection_HappyPath(t *testing.T) { resetLidarrState(t, h) admin := seedAdminUser(t, h) - stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"version":"2.0.5"}`)) + switch r.URL.Path { + case "/api/v1/system/status": + _, _ = w.Write([]byte(`{"version":"2.0.5"}`)) + case "/api/v1/qualityprofile": + _, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"},{"id":2,"name":"MP3-320"}]`)) + case "/api/v1/metadataprofile": + _, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`)) + case "/api/v1/rootfolder": + _, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`)) + default: + http.NotFound(w, r) + } })) t.Cleanup(stub.Close) @@ -243,6 +254,70 @@ func TestHandleTestLidarrConnection_HappyPath(t *testing.T) { if resp["version"] != "2.0.5" { t.Errorf("version = %v, want 2.0.5", resp["version"]) } + qp, ok := resp["quality_profiles"].([]any) + if !ok || len(qp) != 2 { + t.Errorf("quality_profiles = %v, want 2 entries", resp["quality_profiles"]) + } + mp, ok := resp["metadata_profiles"].([]any) + if !ok || len(mp) != 1 { + t.Errorf("metadata_profiles = %v, want 1 entry", resp["metadata_profiles"]) + } + rf, ok := resp["root_folders"].([]any) + if !ok || len(rf) != 1 { + t.Errorf("root_folders = %v, want 1 entry", resp["root_folders"]) + } + if _, present := resp["list_errors"]; present { + t.Errorf("list_errors should be omitted when all lists succeed; got %v", resp["list_errors"]) + } +} + +// TestHandleTestLidarrConnection_PartialListFailure verifies that POST /test +// against a Lidarr that responds OK to the ping + two of three lists still +// reports ok=true with the failing list omitted and named in list_errors. +func TestHandleTestLidarrConnection_PartialListFailure(t *testing.T) { + h, _ := testHandlers(t) + resetLidarrState(t, h) + admin := seedAdminUser(t, h) + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/system/status": + _, _ = w.Write([]byte(`{"version":"2.0.5"}`)) + case "/api/v1/qualityprofile": + _, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"}]`)) + case "/api/v1/metadataprofile": + http.Error(w, "boom", http.StatusInternalServerError) + case "/api/v1/rootfolder": + _, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(stub.Close) + + body := []byte(`{"base_url":"` + stub.URL + `","api_key":"k"}`) + w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if resp["ok"] != true { + t.Errorf("ok = %v, want true", resp["ok"]) + } + if _, present := resp["metadata_profiles"]; present { + t.Errorf("metadata_profiles should be omitted on failure; got %v", resp["metadata_profiles"]) + } + listErrs, ok := resp["list_errors"].(map[string]any) + if !ok { + t.Fatalf("list_errors missing or wrong shape: %v", resp["list_errors"]) + } + if _, present := listErrs["metadata_profiles"]; !present { + t.Errorf("list_errors.metadata_profiles should be present, got %v", listErrs) + } } // TestHandleTestLidarrConnection_Unreachable verifies that POST /test against From abb384f479b7e3d943718923ebba133af1e8ae60 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 23:08:02 -0400 Subject: [PATCH 005/111] feat(web/admin): LidarrTestResult success branch carries profile + folder lists Mirrors the server-side extension of POST /api/admin/lidarr/test. The integrations page consumes these in the next commit to pre-fill the quality/metadata/root-folder dropdowns on first-time Lidarr setup. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/api/types.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index ac41e346..c58ded2f 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -192,9 +192,19 @@ export type LidarrRootFolder = { }; // testLidarrConnection always returns 200; callers branch on `.ok`. +// On success the response carries the live profile + folder lists so the +// integrations page can pre-fill its dropdowns on first-time setup +// without a separate round-trip. export type LidarrTestResult = - | { ok: true; version: string } - | { ok: false; error: string }; + | { + ok: true; + version: string; + quality_profiles?: { id: number; name: string }[]; + metadata_profiles?: { id: number; name: string }[]; + root_folders?: { path: string; accessible: boolean; free_space: number }[]; + list_errors?: Record; + } + | { ok: false; error: string }; // Lidarr quarantine ------------------------------------------------------ From bca862264043bca03a8ca72ac783ca44daee6fe9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 23:08:58 -0400 Subject: [PATCH 006/111] feat(web/admin): Save runs Test first, dropdowns honor test response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click sequence on a fresh Lidarr config: enter URL + API key → click Save. The page now calls testLidarrConnection first, stashes the returned profiles/folders into local state, lets the existing auto-default $effects pick first-of-each, then sends the put-config request with non-zero defaults. If the test fails, the save aborts and the error surfaces in saveError. The explicit Test connection button keeps working and additionally populates the dropdowns from the same response so an operator can inspect/adjust before saving. Each dropdown reads from its test-state array when present, falling back to the existing query data (which only works after the first successful save). list_errors entries surface as inline notes under their respective dropdowns. Closes the chicken-and-egg where saving required defaults that could only be fetched after saving. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../routes/admin/integrations/+page.svelte | 93 +++++++++++++++---- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index 8512c15f..1b5b7553 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -1,4 +1,5 @@ + + + +{#if mobileNav.value} + +{/if} + + diff --git a/web/src/lib/components/MobileNavDrawer.test.ts b/web/src/lib/components/MobileNavDrawer.test.ts new file mode 100644 index 00000000..c094d85b --- /dev/null +++ b/web/src/lib/components/MobileNavDrawer.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import MobileNavDrawer from './MobileNavDrawer.svelte'; +import { mobileNav, openMobileNav, closeMobileNav } from '$lib/stores/mobileNav.svelte'; + +describe('MobileNavDrawer', () => { + beforeEach(() => { + closeMobileNav(); + }); + + it('renders inert + aria-hidden when closed', () => { + render(MobileNavDrawer); + const aside = screen.getByLabelText('Main navigation'); + expect(aside.getAttribute('aria-hidden')).toBe('true'); + expect(aside.hasAttribute('inert')).toBe(true); + }); + + it('flips to visible when mobileNav opens', async () => { + render(MobileNavDrawer); + openMobileNav(); + await Promise.resolve(); + const aside = screen.getByLabelText('Main navigation'); + expect(aside.getAttribute('aria-hidden')).toBe('false'); + expect(aside.hasAttribute('inert')).toBe(false); + }); + + it('renders all nav items + Settings + Log out', () => { + openMobileNav(); + render(MobileNavDrawer); + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('Artists')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText('Log out')).toBeInTheDocument(); + }); + + it('Escape key closes the drawer', () => { + openMobileNav(); + render(MobileNavDrawer); + expect(mobileNav.value).toBe(true); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(mobileNav.value).toBe(false); + }); +}); From df4cb434b27610732269f592cd71d68fd8c42aae Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 20:17:35 -0400 Subject: [PATCH 046/111] feat(web): hamburger trigger + mobile drawer mount in Shell Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/Shell.svelte | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 4e29359a..09d4f7f8 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -1,12 +1,14 @@ + + { menuOpen = false; }} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> + +
+ + + {#if menuOpen} + + {/if} +
diff --git a/web/src/lib/components/PlayerOverflowMenu.test.ts b/web/src/lib/components/PlayerOverflowMenu.test.ts new file mode 100644 index 00000000..8d71c12f --- /dev/null +++ b/web/src/lib/components/PlayerOverflowMenu.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import PlayerOverflowMenu from './PlayerOverflowMenu.svelte'; + +describe('PlayerOverflowMenu', () => { + it('renders the trigger button closed by default', () => { + render(PlayerOverflowMenu); + const trigger = screen.getByLabelText('Player options'); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('opens the menu on click and shows shuffle/repeat/queue/volume', async () => { + render(PlayerOverflowMenu); + const trigger = screen.getByLabelText('Player options'); + await fireEvent.click(trigger); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + expect(screen.getByText(/shuffle/i)).toBeInTheDocument(); + expect(screen.getByText(/repeat/i)).toBeInTheDocument(); + expect(screen.getByText(/queue/i)).toBeInTheDocument(); + expect(screen.getByLabelText('Volume')).toBeInTheDocument(); + }); + + it('Escape key closes the menu', async () => { + render(PlayerOverflowMenu); + await fireEvent.click(screen.getByLabelText('Player options')); + expect(screen.getByLabelText('Player options').getAttribute('aria-expanded')).toBe('true'); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + await Promise.resolve(); + expect(screen.getByLabelText('Player options').getAttribute('aria-expanded')).toBe('false'); + }); +}); From ed6d0936ca05a0e9915f10ee7685fc5118e1cd77 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 20:18:32 -0400 Subject: [PATCH 048/111] feat(web): PlayerBar reflows to two-row layout below md Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/PlayerBar.svelte | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte index 405e6690..a3c90121 100644 --- a/web/src/lib/components/PlayerBar.svelte +++ b/web/src/lib/components/PlayerBar.svelte @@ -15,6 +15,7 @@ import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; import LikeButton from './LikeButton.svelte'; import TrackMenu from './TrackMenu.svelte'; + import PlayerOverflowMenu from './PlayerOverflowMenu.svelte'; const current = $derived(player.current); @@ -48,9 +49,9 @@ {#if current} -
+
-
+
@@ -73,13 +74,14 @@ {#if player.queue.length > player.index + 1} {@const nextTrack = player.queue[player.index + 1]} - + {/if}
+
@@ -138,7 +140,7 @@
- + {formatDuration(player.position)} - + {formatDuration(player.duration)}
@@ -159,7 +161,7 @@ {/if} -
+ diff --git a/web/src/lib/components/RowActionsMenu.test.ts b/web/src/lib/components/RowActionsMenu.test.ts new file mode 100644 index 00000000..53696512 --- /dev/null +++ b/web/src/lib/components/RowActionsMenu.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { Check, X, Trash2 } from 'lucide-svelte'; +import RowActionsMenu from './RowActionsMenu.svelte'; + +describe('RowActionsMenu', () => { + it('renders the primary action inline', () => { + const onApprove = vi.fn(); + render(RowActionsMenu, { + props: { + primary: { icon: Check, label: 'Approve', onclick: onApprove }, + secondary: [] + } + }); + expect(screen.getByText('Approve')).toBeInTheDocument(); + }); + + it('fires the primary action onclick', async () => { + const onApprove = vi.fn(); + render(RowActionsMenu, { + props: { + primary: { icon: Check, label: 'Approve', onclick: onApprove }, + secondary: [] + } + }); + await fireEvent.click(screen.getByText('Approve')); + expect(onApprove).toHaveBeenCalledOnce(); + }); + + it('renders secondary action labels and exposes the overflow trigger', () => { + render(RowActionsMenu, { + props: { + primary: { icon: Check, label: 'Approve', onclick: vi.fn() }, + secondary: [ + { icon: X, label: 'Reassign', onclick: vi.fn() }, + { icon: Trash2, label: 'Delete', onclick: vi.fn(), danger: true } + ] + } + }); + // Both inline (md:flex) and overflow-trigger (md:hidden) markup are + // present in jsdom; the test confirms labels exist and the overflow + // trigger is in the DOM. + expect(screen.getAllByText('Reassign').length).toBeGreaterThan(0); + expect(screen.getAllByText('Delete').length).toBeGreaterThan(0); + expect(screen.getByLabelText('More actions')).toBeInTheDocument(); + }); + + it('opens the overflow menu when ⋮ is clicked', async () => { + render(RowActionsMenu, { + props: { + primary: { icon: Check, label: 'Approve', onclick: vi.fn() }, + secondary: [{ icon: X, label: 'Reassign', onclick: vi.fn() }] + } + }); + const trigger = screen.getByLabelText('More actions'); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + await fireEvent.click(trigger); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + }); + + it('omits the overflow trigger when secondary is empty', () => { + render(RowActionsMenu, { + props: { + primary: { icon: Check, label: 'Approve', onclick: vi.fn() }, + secondary: [] + } + }); + expect(screen.queryByLabelText('More actions')).not.toBeInTheDocument(); + }); +}); From e266a0f2dc312708fb5254c5edf0d84c6b8a95c0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 20:20:43 -0400 Subject: [PATCH 050/111] feat(web): wire RowActionsMenu in requests + quarantine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requests: primary Approve, secondary [Override, Reject]. Quarantine: primary Resolve, secondary [Play, Delete file]; the Delete-via-Lidarr conditional stays inline above md (preserves the disabled-with-tooltip semantics) and hides below md to avoid crowding — the operator can still trigger it from the desktop view. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/routes/admin/quarantine/+page.svelte | 39 +++++--------------- web/src/routes/admin/requests/+page.svelte | 36 ++++-------------- 2 files changed, 17 insertions(+), 58 deletions(-) diff --git a/web/src/routes/admin/quarantine/+page.svelte b/web/src/routes/admin/quarantine/+page.svelte index 6e369b88..e1645999 100644 --- a/web/src/routes/admin/quarantine/+page.svelte +++ b/web/src/routes/admin/quarantine/+page.svelte @@ -2,6 +2,7 @@ import { pageTitle } from '$lib/branding'; import { Music2, RotateCcw, Trash2, Cloud, Play, ChevronRight } from 'lucide-svelte'; import { useQueryClient } from '@tanstack/svelte-query'; + import RowActionsMenu, { type RowAction } from '$lib/components/RowActionsMenu.svelte'; import { createAdminQuarantineQuery, resolveQuarantine, @@ -258,41 +259,19 @@
+ {@const primary: RowAction = { icon: RotateCcw, label: 'Resolve', onclick: () => onResolve(r) }} + {@const secondary: RowAction[] = [ + { icon: Play, label: 'Play', onclick: () => onPlay(r) }, + { icon: Trash2, label: 'Delete file', onclick: () => openDeleteFile(r), danger: true } + ]}
- - - - - + {#if lidarrDisabled} @@ -303,7 +282,7 @@
{#if r.status === 'pending'} -
- - - + {@const primary: RowAction = { icon: Check, label: 'Approve', onclick: () => onApprove(r) }} + {@const secondary: RowAction[] = [ + { icon: SlidersHorizontal, label: 'Override', onclick: () => openOverride(r) }, + { icon: X, label: 'Reject', onclick: () => openReject(r), danger: true } + ]} +
+
{/if} From f03a0042e872caa1da452425214377d767b05d81 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 20:21:17 -0400 Subject: [PATCH 051/111] feat(web): wire RowActionsMenu in users admin page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primary: toggle admin (most semantically distinct per-user action). Secondary: toggle auto-approve, reset password, delete (danger). 'Delete' was previously styled as a regular border button — now correctly marked danger. No Edit action existed; not adding one. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/routes/admin/users/+page.svelte | 68 +++++++++++-------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index 2b7f4226..93d06797 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -19,6 +19,8 @@ import { errCode } from '$lib/api/errors'; import { pushToast } from '$lib/stores/toast.svelte'; import Modal from '$lib/components/Modal.svelte'; + import RowActionsMenu, { type RowAction } from '$lib/components/RowActionsMenu.svelte'; + import { Shield, ShieldOff, CheckCircle2, XCircle, KeyRound, Trash2 } from 'lucide-svelte'; const client = useQueryClient(); @@ -254,43 +256,35 @@ {#if u.display_name}{u.display_name} · {/if}created {formatDate(u.created_at)}
-
- - - - + {@const primary: RowAction = { + icon: u.is_admin ? ShieldOff : Shield, + label: u.is_admin ? 'Remove admin' : 'Make admin', + onclick: () => onToggleAdmin(u), + disabled: saving + }} + {@const secondary: RowAction[] = [ + { + icon: u.auto_approve_requests ? XCircle : CheckCircle2, + label: u.auto_approve_requests ? 'Disable auto-approve' : 'Enable auto-approve', + onclick: () => onToggleAutoApprove(u), + disabled: saving + }, + { + icon: KeyRound, + label: 'Reset password', + onclick: () => { resetPasswordTarget = u; }, + disabled: saving + }, + { + icon: Trash2, + label: 'Delete', + onclick: () => { confirmDeleteTarget = u; }, + disabled: saving, + danger: true + } + ]} +
+
{/each} From 16e0e98943eec99aea85f59dfe567c7f4f2c34d8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 20:21:38 -0400 Subject: [PATCH 052/111] =?UTF-8?q?feat(web):=20mobile=20mechanical=20swee?= =?UTF-8?q?p=20=E2=80=94=20pointer-aware=20arrows,=20h3=20dividers,=20moda?= =?UTF-8?q?l=20safe-area?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HorizontalScrollRow arrows bump 32px → 40px on coarse pointers (touch screens) for finger-friendly hits; mouse pointers unchanged. - AlphabeticalGrid divider letter is now

so screen readers announce it as a section heading. Margin reset to preserve layout. - Modal overlay gets p-4 so max-w-md modals stay inside the safe area at 375px viewport. Covers all routes that use the shared component (discover track-confirm, quarantine typed-DELETE, users password-reset, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/AlphabeticalGrid.svelte | 4 +++- web/src/lib/components/HorizontalScrollRow.svelte | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/AlphabeticalGrid.svelte b/web/src/lib/components/AlphabeticalGrid.svelte index 33f2c687..4a735a67 100644 --- a/web/src/lib/components/AlphabeticalGrid.svelte +++ b/web/src/lib/components/AlphabeticalGrid.svelte @@ -33,7 +33,7 @@ {#each segments as seg, i (i)} {#if seg.divider}
- {seg.divider} +

{seg.divider}

{/if} @@ -61,6 +61,8 @@ font-size: 24px; font-weight: 500; color: var(--fs-parchment); + /* h3 default browser margin would push the divider; reset. */ + margin: 0; } .rule { flex: 1; diff --git a/web/src/lib/components/HorizontalScrollRow.svelte b/web/src/lib/components/HorizontalScrollRow.svelte index 1bd92f23..bdb91497 100644 --- a/web/src/lib/components/HorizontalScrollRow.svelte +++ b/web/src/lib/components/HorizontalScrollRow.svelte @@ -152,4 +152,12 @@ } .arrow:hover:not(:disabled) { color: var(--fs-parchment); } .arrow:disabled { opacity: 0.3; cursor: not-allowed; } + /* Coarse pointers (touch screens) get larger arrows for finger-friendly + hit area; mouse pointers keep the visually compact 32px size. */ + @media (pointer: coarse) { + .arrow { + width: 40px; + height: 40px; + } + } From 268e12a5ebcc033b9e7fe3001902b70fbc72917d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 20:21:52 -0400 Subject: [PATCH 053/111] feat(web): Modal overlay gets p-4 so max-w-md modals fit on phone Without padding, max-w-md (448px) modals touch the viewport edges on a 375px screen. p-4 on the overlay clamps them inside the safe area. Covers all routes that use the shared component (discover track-confirm, quarantine typed-DELETE, users password-reset, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/Modal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/Modal.svelte b/web/src/lib/components/Modal.svelte index 32257700..0ad19092 100644 --- a/web/src/lib/components/Modal.svelte +++ b/web/src/lib/components/Modal.svelte @@ -39,7 +39,7 @@ {#if open} -