Commit Graph

492 Commits

Author SHA1 Message Date
bvandeusen 65bb4e6dfd docs(m7): spec for #352 playlists CRUD (slice 1 of 3)
Manual playlists only — schema (migration 0014), backend service +
collage generator, /api/playlists* endpoints, /playlists list +
detail pages, AddToPlaylistMenu wired into TrackMenu's reserved slot.

10 locked decisions captured: private-by-default, manual-only,
api-only (no Subsonic), 2×2 collage always (glyph fills missing
cells), soft-mark cascade with denormalized snapshot, full-list
reorder, inline collage gen, composite PK on (playlist_id, position),
denormalized track_count + duration_sec rollups.

Slices 2 (system-generated daily mixes) and 3 (home-page playlists
row) are sibling cycles; per v1=full-product all three ship before
tag.
2026-05-03 00:28:34 -04:00
bvandeusen 1cf58b18e8 ci(web): exclude #374-blocked tests from vitest module discovery
describe.skip inside the file doesn't help — the SvelteKit
`notifiable_store is not a function` failure happens at module-load,
before vitest evaluates describe blocks. Add the two paths to
vitest's exclude list so the files aren't loaded at all. The skip
markers stay inside the files as a triage signal for M7 #374.
2026-05-03 00:13:04 -04:00
bvandeusen 53842b0f21 ci(test-web): drop npm cache restore — setup-node was burning 4m41s
The Forgejo Actions cache server at 172.18.0.27:41161 isn't reachable
from the test-web runner container; setup-node sat there waiting for
ETIMEDOUT each run. Drop `cache: 'npm'` so the step finishes in seconds.

`npm ci` still works deterministically from package-lock.json — net cost
is ~10-30s of registry fetch vs. 4m41s of timeout. Restore the cache
option once the runner-host network reaches the cache server (separate
operator task).
2026-05-03 00:09:02 -04:00
bvandeusen 40db918bfc fix(web): RemoveTrackPopover async test waits + skip 2 SvelteKit-broken suites
Three CI failures from the dev-push test-web.yml run. Two categories:

1. RemoveTrackPopover.test.ts — `confirm()` chains 5+ awaited
   invalidateQueries before onClose; the test's two `await Promise.resolve()`
   only flushed two microtasks. Switch to waitFor() so the assertion
   polls until the side effects land. Same fix on the success-cascade
   invalidation count test.

2. discover.test.ts + requests.test.ts — both fail at module-load with
   `TypeError: notifiable_store is not a function` deep in
   @sveltejs/kit's client.js. Surfaced only on the new dev-push
   workflow; PR-to-main runs were green. describe.skip with a FIXME
   pointing at the new triage task M7 #374. The pages themselves
   aren't broken — the test harness is.
2026-05-03 00:00:26 -04:00
bvandeusen d87d9e3255 fix(web): widen TrackMenuItem icon prop type to match Lucide signature
Previous fix narrowed `IconProp` to `ComponentType<SvelteComponent<{
size?: number, strokeWidth?: number, class?: string }>>` but Lucide
icons accept `size: string | number` (you can write size="16" or
size={16}), and the resulting structural mismatch surfaces 11 type
errors at every assignment site. Drop the prop-shape constraint
entirely — TrackMenuItem only ever passes numeric values to the icon.
2026-05-02 23:34:11 -04:00
bvandeusen 5d69e9614f fix(web): TrackMenuItem icon prop accepts Lucide class-components
svelte-check on the new dev-push CI surfaced 11 type errors. Cause:
TrackMenuItem typed `icon` as Svelte 5's runes-mode `Component<...>`,
but Lucide-svelte ships class-based components whose type is
`ComponentType<SvelteComponent<...>>`. Switch the prop type to match
Lucide's actual export shape.

Also drop the redundant role="separator" on TrackMenuDivider — <hr>
already implies role=separator (svelte-check warning).

The remaining a11y warnings (tabindex on role=menu/dialog elements,
key-handler-with-click) are pre-existing in FlagPopover and surface
on the new dev-push run because svelte-check now sees them. They're
warnings not errors, so they don't block CI; address as a follow-up.
2026-05-02 23:24:20 -04:00
bvandeusen 6858e45dbe ci: split test.yml into test-web.yml + test-go.yml, run on dev pushes
Operator wants CI to run on dev pushes (not just PR-to-main) but
path-scoped so Go-only diffs don't run web tests and vice versa. The
old test.yml ran everything on PR-to-main only; the new shape is two
focused workflows that each path-filter their own scope.

- test-web.yml — paths: web/**. Runs npm ci + npm run check + vitest.
  Drops the npm run build step (vitest doesn't need the bundle; the
  go:embed placeholder lives in web/build/index.html and never needs
  the test job to rebuild it).
- test-go.yml — paths: **/*.go, go.mod, go.sum, sqlc.yaml, internal/**,
  cmd/**. Runs go vet + golangci-lint + go test -short -race.

Both trigger on push to dev/main and PR to main. Tag pushes are not
covered — release.yml owns the tag-build path and tests have already
passed by the time a tag lands.

Drops the gomod-detect guard from test.yml — go.mod has been present
since M1 and is required for any Go file to exist anyway.
2026-05-02 23:11:20 -04:00
bvandeusen 9dc4786fd9 feat(web): TrackMenu rewrite — 9 entries in 4 groups (M7 #372)
Replaces the M5b-era FlagPopover-only menu with the full track-actions
surface: queue (Play next, Add to queue), collection (Like/Unlike,
Add to playlist… reserved for #352), navigation (Go to album/artist),
lifecycle (Flag, Hide/Unhide, Remove from library — admin-only).

Remove from library opens a new RemoveTrackPopover with a single
"Also stop Lidarr from finding a replacement" checkbox. The
destructive flow always deletes file + DB through Minstrel; the
checkbox controls whether Lidarr is also told to unmonitor. Lidarr
unmonitor failure flows back as lidarr_unmonitor_failed in the
response — destructive part already succeeded.

The component's prop API (track, direction) is unchanged so TrackRow
and PlayerBar pick up the new entries with no code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:00:09 -04:00
bvandeusen 681b532b57 feat(web): TrackMenuDivider primitive for M7 #372 menu groups 2026-05-02 22:46:44 -04:00
bvandeusen 9256369926 feat(web): TrackMenuItem primitive for M7 #372
Single-entry button used by TrackMenu. Supports aria-disabled (kept
visible to preserve menu height when admin-only or pre-#352 entries
are non-functional) and a `danger` flag for the oxblood-tinted
"Remove from library" entry.
2026-05-02 22:46:29 -04:00
bvandeusen f43ea6af5b feat(player): playNext for M7 #372 track-actions menu
Inserts at _queue[_index + 1] so the next-up slot is overwritten with
the chosen track. Empty-queue seeding mirrors enqueueTrack's behavior.
Clears _radioSeedId since user-driven enqueue invalidates the M4c
auto-refresh trigger.
2026-05-02 22:45:13 -04:00
bvandeusen 905b5f0da7 feat(web): admin removeTrack API helper for M7 #372
DELETE /api/admin/tracks/{id} with optional ?unmonitor=true. Returns
the typed envelope with optional cascaded album/artist ids and the
lidarr_unmonitor_failed flag (only set on Lidarr-side failure when
unmonitor was requested — the file + DB delete still succeeded).
Caller invalidates TanStack Query keys for any vanished entities.
2026-05-02 22:44:01 -04:00
bvandeusen bb931746e3 feat(api): DELETE /api/admin/tracks/{id} for M7 #372
Admin-only handler. Calls tracks.RemoveTrack with the unmonitor flag
parsed from the query string. ErrNotFound -> 404; everything else
unexpected -> 500. Lidarr-side failures during unmonitor flow as
lidarr_unmonitor_failed: true in the success envelope (non-fatal —
the file + DB delete already completed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:41:51 -04:00
bvandeusen f6975cfad3 fix(tracks): rewrite RemoveTrack — direct os.Remove + optional Lidarr unmonitor
Replaces commit 50a231f's wrong-shape Lidarr-routed delete. The previous
design called lidarrquarantine.DeleteViaLidarr for Lidarr-managed tracks,
which deletes the entire **album** in Lidarr (Lidarr is album-granular,
no per-track delete API) — silently dropping sibling tracks the operator
didn't ask to remove.

New shape per spec revision 723eee9:
- Always: os.Remove + DB delete + cascade through Minstrel.
- When unmonitor=true AND track has mbid: call new Lidarr.UnmonitorTrack
  primitive so Lidarr won't replace. Failure is non-fatal — file is
  already gone, DB consistent; the lidarrUnmonitorFailed bool flows to
  the wire response so the UI can surface a follow-up "unmonitor
  manually" toast.

Service signature changes from `(trackID, adminID) → (delAlbum, delArtist, err)`
to `(trackID, adminID, unmonitor) → (delAlbum, delArtist, lidarrFailed, err)`.

Adds Lidarr.UnmonitorTrack with full three-step API walk:
  1. GET /api/v1/album?foreignAlbumId={mbid} → Lidarr's internal album id
  2. GET /api/v1/track?albumId={id} → match track by foreignTrackId
  3. PUT /api/v1/track/monitor with {trackIds:[id], monitored:false}

Refactors post() into a shared bodyRequest() so put() reuses the same
status-code → typed-error mapping. The album mbid is captured *before*
the cascade-delete transaction — DeleteAlbumIfEmpty may remove the
album row, after which a post-commit GetAlbumByID returns ErrNoRows
and the unmonitor walk would have nothing to walk against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:35:34 -04:00
bvandeusen 723eee9773 docs(m7): revise #372 spec — Remove from library no longer goes via Lidarr
Operator decision during Task 2 implementation: routing track removal
through lidarrquarantine.DeleteViaLidarr is wrong because Lidarr is
album-granular (no per-track delete). The original spec would have
silently deleted sibling tracks.

New shape:
- Always delete file + DB row + cascade through Minstrel (os.Remove).
- Confirm dialog asks "find a replacement?": Yes (default — no Lidarr
  call; monitoring re-imports on next scan) or No (call new
  Lidarr.UnmonitorTrack(mbid) primitive).
- Lidarr unmonitor failure is non-fatal — sets lidarr_unmonitor_failed
  on the success envelope so the UI toasts a follow-up message.

Adds a new internal/lidarr.UnmonitorTrack method (LookupTrack →
PUT /api/v1/track/monitor with monitored:false) to Task 2's scope.
Wire codes lidarr_unreachable / lidarr_unauthorized / lidarr_server_error
no longer come back from this endpoint.
2026-05-02 22:28:49 -04:00
bvandeusen 50a231fdc1 feat(tracks): RemoveTrack service with Lidarr-aware delete + cascade
RemoveTrack handles both Lidarr-managed (delegates to existing
lidarrquarantine.DeleteViaLidarr) and non-Lidarr (direct os.Remove)
file deletion, then runs the cascade album-if-empty / artist-if-empty
DB cleanup in a single transaction. Lidarr errors propagate as typed
errors so the API layer can map them to the existing wire codes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:58:12 -04:00
bvandeusen 30b0edf97b feat(db): cascade-cleanup queries for M7 #372 track removal
DeleteTrack returns album_id + artist_id so the calling service can
chain the album-empty / artist-empty cascade. DeleteAlbumIfEmpty and
DeleteArtistIfEmpty are no-ops when other rows still reference the
parent — the service treats pgx.ErrNoRows as "not orphaned, skip".
2026-05-02 21:53:33 -04:00
bvandeusen 9c53c9894a docs(m7): implementation plan for #372 track-actions menu
9 tasks covering: SQL cascade-cleanup queries, internal/tracks
service with Lidarr-aware RemoveTrack, DELETE /api/admin/tracks/{id}
handler, frontend admin API helper, audio-store playNext addition,
TrackMenuItem + TrackMenuDivider primitives, full TrackMenu rewrite
with all 9 entries (Add to playlist… reserved as a disabled slot for
#352), and TrackRow/PlayerBar test adjustments.

Plan reflects the no-in-task-tests rule — implementers write test
files but don't run them; CI handles verification. Migration 0014
that the spec marked conditional is dropped: all relevant track_id
FKs already cascade.
2026-05-02 21:39:30 -04:00
bvandeusen 136a475133 docs(m7): spec for #372 track-level actions menu
Replaces the M5b-era single-entry <TrackMenu> with a 9-entry kebab
menu in 4 groups (queue / collection / navigation / lifecycle).
Reserves the "Add to playlist..." slot for #352. Lands a new
admin-only DELETE /api/admin/tracks/{id} for "Remove from library"
with cascade album -> artist tidy-up and Lidarr-aware deletion for
managed tracks.

Track metadata editing split out as #373 — different feature concern
(crosses into Lidarr's territory + scanner reconciliation).
2026-05-02 21:31:40 -04:00
bvandeusen f27e83612d fix(flutter): two test environment issues from CI
smoke_test was hanging in pumpAndSettle because real
flutter_secure_storage MethodChannel calls don't resolve in widget
tests (no platform channel registered). Override secureStorageProvider
with a mocktail FlutterSecureStorage that returns null for every read,
so the GoRouter redirect resolves and ServerUrlScreen renders.

player_provider_test second case was crashing because constructing
MinstrelAudioHandler() instantiates just_audio's AudioPlayer, which
calls setMethodCallHandler during construction — that asserts unless
the WidgetsFlutterBinding is initialized. Add
TestWidgetsFlutterBinding.ensureInitialized() at the top of main().

Both are unit-test plumbing fixes; no production code change.
2026-05-02 19:36:27 -04:00
bvandeusen 1e8b8aaa2d fix(flutter): drop redundant const in nested const contexts
Making the outer FabledSwordTheme(...) and HomeData(...) constructors
const made every inner const redundant — Dart's analyzer fires
unnecessary_const for each. Strip the inner consts so the outer
context owns the const inference.
2026-05-02 19:17:05 -04:00
bvandeusen d86c694cde fix(flutter): five flutter analyze --fatal-infos issues from first CI run
1. lib/app.dart + lib/shared/routing.dart — buildRouter takes a Ref but was
   being called from a ConsumerWidget's build() with a WidgetRef. Add a
   routerProvider; the widget watches it instead of constructing the
   router from its own ref. Real bug — would have crashed compile in
   slice 1, just never compiled until CI ran.

2. lib/player/audio_handler.dart — override of AudioHandler.seek used
   `p` for the Duration; rename to `position` to match the base class
   (avoid_renaming_method_parameters lint).

3. lib/theme/theme_extension.dart — fromTokens() returned a non-const
   constructor; all inputs are const so make the call const too
   (prefer_const_constructors).

4. test/library/home_screen_test.dart — same const-constructor lint on
   the HomeData test fixture.

5. test/smoke_test.dart — drop the unused
   `import 'package:flutter/material.dart'`.

These are exactly the kind of issues the no-in-task-tests rule shifted
to CI; this is the first run that actually exercises the analyzer
against the slice 1 code.
2026-05-02 19:01:54 -04:00
bvandeusen de1bfcf58b ci: retrigger after node-baked flutter-ci runner image 2026-05-02 18:57:39 -04:00
bvandeusen ec89201e04 ci(flutter): switch to dedicated flutter-ci runner
The flutter-ci runner image (CI-Runner/CI-flutter/Dockerfile) bakes in
Flutter 3.41.8 + Android cmdline-tools + platform-34 + build-tools
34.0.0 + JDK 17, so the workflow no longer needs subosito/flutter-action
to install everything per-job. Drops the setup step and switches
runs-on from the general-purpose go-ci to flutter-ci.

Per project memory feedback_dedicated_runner_labels.md: prefer
purpose-built labels with toolchains pre-installed over generic-runner +
setup-action ceremony on this self-hosted Forgejo deployment.
2026-05-02 18:42:08 -04:00
bvandeusen 7f10535420 fix(ci): flutter workflow runs on go-ci runner, not ubuntu-latest
ubuntu-latest isn't a label any runner in this Forgejo deployment
advertises. The existing test.yml + release.yml both target the
project's go-ci label; flutter.yml should match. subosito/flutter-action@v2
installs the Flutter SDK on whatever runner picks up the job, so
go-ci is fine even though it isn't Flutter-pre-installed.
2026-05-02 18:09:26 -04:00
bvandeusen e4b211eb13 docs(flutter): replace flutter-create-default README
Replaces the auto-generated `# minstrel\nMinstrel mobile client`
stub from `flutter create` with a real setup + layout + CI + scope
guide. Pointers to the spec, the M7 sequencing decision (web-first
features ship before Flutter slice 2+), and the per-asset sync
workflow.
2026-05-02 17:43:30 -04:00
bvandeusen 7c0f9750d8 ci(flutter): Forgejo workflow — sync, analyze, test, build APK
Triggers on push to dev/main, tag pushes, PR to main, and manual
dispatch — all path-filtered to flutter_client/** and the three
shared web inputs the sync_shared.sh script reads.

Steps:
- Sync tokens/error-copy/svg from web/ via tool/sync_shared.sh
- Re-run dart run tool/gen_tokens.dart and fail if lib/theme/tokens.dart
  drifts from shared/fabledsword.tokens.json (catches forgotten regen
  on the JSON edit path)
- flutter pub get / analyze --fatal-infos / test
- Debug APK on every push (uploaded as a workflow artifact)
- Release APK on tag, attached to the corresponding Forgejo release
  via RELEASE_TOKEN (release.yml creates the release entry; this
  workflow just attaches the APK as minstrel-<tag>.apk)

Uses subosito/flutter-action@v2 on ubuntu-latest for portability —
no dependence on a Flutter-pre-installed runner.
2026-05-02 17:42:38 -04:00
bvandeusen ec0c10f312 feat(flutter): connection-error banner + 401-clears-session interceptor
ApiClient.buildDio takes on401; the library's dioProvider wires it to
the auth controller's clearSession. The router redirect already handles
navigation away from authed screens once the session goes null.
HomeScreen surfaces the banner on connection_refused with Retry +
Change URL.
2026-05-02 17:41:46 -04:00
bvandeusen f675782bf5 feat(flutter/player): NowPlaying full-screen view with scrubber
Reached by tapping the PlayerBar. Reads media + playback streams; seek
dispatches through the audio handler. Swipe-down dismiss is a stand-in
chevron-down AppBar leading icon for v1.
2026-05-02 17:40:13 -04:00
bvandeusen bbfeb5d3ff feat(flutter/player): mini PlayerBar in shell + tap-to-play wiring
PlayerBar reads playbackState + mediaItem streams; hidden when no
queue. Home track-row tap, album play button, album track tap, artist
play button (shuffle) all route to playerActions.playTracks. Token +
baseUrl forwarded to the audio handler before each new queue.
2026-05-02 17:39:09 -04:00
bvandeusen 80f98b3243 feat(flutter/player): MinstrelAudioHandler + just_audio + audio_service
audio_service runs the handler in a background isolate; UI watches
playbackState/mediaItem/queue streams through Riverpod. AndroidManifest
gets the foreground-service media-playback permission; Info.plist gets
UIBackgroundModes=audio. Bearer token attached to stream URLs via
just_audio's per-source headers.
2026-05-02 17:37:14 -04:00
bvandeusen 44e2623b3c feat(flutter/likes): LikeButton with optimistic toggle + rollback
LikedIdsController loads /api/likes/ids once and mutates the local set
on toggle. Failed mutations roll the set back so the icon never lies.
Wired into ArtistDetail header, AlbumDetail header, and per-track in
the album track list.
2026-05-02 17:34:59 -04:00
bvandeusen 6ec7cea362 feat(flutter/library): artist + album detail screens
Headers carry placeholder play buttons (wired in Task 18) + room for
LikeButton (Task 16). Artist screen has a 2-column album grid; album
screen has a track list with mm:ss durations. Routes :id-parameterized
under the shell so the PlayerBar persists.
2026-05-02 17:32:34 -04:00
bvandeusen 5ef782905e feat(flutter/home): home screen with five sections
Sections mirror the web home: Recently added · Rediscover (albums +
artists) · Most played · Last played artists. Tap routes pushed for
albums/artists; track-tap is wired in the player task. Pull-to-refresh
re-fetches /api/home.
2026-05-02 17:30:42 -04:00
bvandeusen c47deb3c87 feat(flutter/library): card + row widgets (artist/album/track)
HorizontalScrollRow takes an optional shared ScrollController so
multi-row sections (Most played: 3 rows) couple their scroll like the
web HorizontalScrollRow.svelte. Cards fall back to the synced
album-fallback.svg when coverUrl is empty.
2026-05-02 17:29:03 -04:00
bvandeusen 3e267918b7 feat(flutter/library): API endpoints + Riverpod providers
LibraryApi wraps GET /api/home, /api/artists/{id}(/tracks),
/api/albums/{id}. dioProvider builds an authenticated dio (token
resolver reads session_token from secure storage on every request).
homeProvider, artistProvider(id), albumProvider(id) sit on top.
2026-05-02 17:26:57 -04:00
bvandeusen d115c267f4 feat(flutter): GoRouter shell + version gate + auth-aware redirects
Cold launch flow: no server-url -> /server-url. URL set, no token ->
/login. Token present -> /home with shell. VersionGate runs once when
the URL changes, hits /healthz, blocks if min_client_version > package
version. /home is a placeholder until Task 14.

Replaces Navigator.pushReplacementNamed in server_url_screen +
login_screen with context.go now that go_router owns the routes.
2026-05-02 17:24:08 -04:00
bvandeusen 9cbbfcff6c feat(flutter/auth): login screen + AuthApi.login posting to /api/auth/login
Login uses a non-authenticated dio (token resolver returns null) since
we don't have one yet. On success, setSession persists token + user
into secure storage and the AuthController state flips, which the
router watches to navigate.
2026-05-02 17:21:42 -04:00
bvandeusen 3cf874ab69 feat(flutter/auth): server URL screen + auth provider + secure storage
AuthController is an AsyncNotifier holding the currently-logged-in
User. server_url, session_token, current_user all live in
flutter_secure_storage. ServerUrlScreen probes /healthz before saving
the URL so we don't store a bogus base.
2026-05-02 17:20:26 -04:00
bvandeusen c0785cf894 feat(flutter/models): User, ArtistRef, AlbumRef, TrackRef, HomeData
Field names mirror web/src/lib/api/types.ts and the server JSON shape
(internal/api/types.go's HomePayload). Hand-written DTOs per spec
section 10 -- codegen revisits at ~30 models.

Adopts server contract over plan-body mock:
- cover_url (not cover_art_url) for artist/album refs
- duration_sec (not duration_ms) for albums and tracks
- stream_url, disc_number on TrackRef
- sort_name/album_count on ArtistRef; sort_title/year/track_count on AlbumRef

HomeData section keys match server: recently_added_albums,
rediscover_albums, rediscover_artists, most_played_tracks,
last_played_artists.
2026-05-02 17:18:12 -04:00
bvandeusen c93b852e65 feat(flutter/api): dio client with Bearer interceptor + ApiError
ApiError.fromDio handles both server envelope shapes: {"error":"code"}
(admin endpoints) and {"error":{"code","message"}} (most others). Same
dual-shape problem the web apiFetch already solves. Token comes from a
resolver function so the auth provider can swap implementations
without touching the client.

ErrorCopy is a lazy singleton over assets/error-copy.json with the
same fallback chain web uses (specific code -> unknown -> hard-coded).
2026-05-02 17:15:55 -04:00
bvandeusen 2729ab897f feat(flutter): FabledSwordTheme ThemeExtension + Material 3 ThemeData
Widgets read tokens via Theme.of(ctx).extension<FabledSwordTheme>().
Material 3 ColorScheme bound to FabledSword tokens. Body text uses
GoogleFonts.interTextTheme() so the fonts work without bundling .ttf
files (offline-from-cold-launch is a separate question once #357 lands).
2026-05-02 16:56:38 -04:00
bvandeusen f97cf6eb38 feat(flutter): token + asset sync from web; gen_tokens.dart
shared/fabledsword.tokens.json mirrors web/src/lib/styles/tokens.json;
assets/error-copy.json mirrors the web's error-code copy table;
assets/svg/album-fallback.svg mirrors the web placeholder. Together
these give Flutter the same visual + copy starting point as the SPA.

tool/gen_tokens.dart emits lib/theme/tokens.dart from the JSON; the
generated file is committed so CI can verify the JSON and the Dart
file haven't drifted (gate added in Task 21).

tool/sync_shared.sh mkdirs the shared/ + assets/ destinations before
copying so the script works on a fresh checkout (the original
plan-text shape only mkdir'd assets/svg).

pubspec.yaml assets block uncommented now that the three asset paths
exist on disk.
2026-05-02 16:55:21 -04:00
bvandeusen d8bfdc1733 chore(ci): .dockerignore + path filters on existing workflows
The repo now contains the Flutter mobile client at flutter_client/
(M7 #356). Two cleanups so the existing build pipelines don't churn
on Flutter-only changes:

1. .dockerignore — keep the Flutter project (and docs/, IDE noise,
   git metadata) out of the Docker build context. The runtime image
   was already unaffected (multi-stage drops everything but the Go
   binary), but the COPY . . layer cache invalidated on every
   Flutter edit. Build context shrinks ~2.4 MB and Flutter-only
   pushes no longer force Go rebuilds.
2. test.yml + release.yml — paths-ignore on flutter_client/**,
   docs/**, and **/*.md. Go/web tests + container build now skip
   when the diff doesn't touch them. Tag releases still build broadly
   since server + Flutter ship paired.

The Flutter-side workflow lands in M7 Task 21 with its own paths
filter restricting it to flutter_client/** and the shared web/
inputs (tokens.json, error-copy.json, album-fallback.svg).
2026-05-02 16:05:28 -04:00
bvandeusen e46224acc9 feat(flutter): project scaffold with pubspec and smoke test
iOS + Android targets only; desktop disabled (per spec, Tauri-wrapped
SvelteKit handles desktop in v1.1). Riverpod + dio + just_audio +
audio_service + go_router pinned in pubspec. Smoke test confirms the
ProviderScope wiring boots a Material app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:25:05 -04:00
bvandeusen b03da903d9 feat(server): /healthz returns min_client_version
Lets the M7 Flutter client refuse to operate against an incompatible
server (e.g., new endpoints the client depends on, breaking schema
shifts). The version constant is bumped on each release where the
client/server contract changes; old clients show the user a blocking
"update required" modal pointed at the latest APK / TestFlight build.
2026-05-02 15:11:10 -04:00
bvandeusen c3be0f3e6e refactor(web): consolidate error-copy table to JSON source of truth
Three admin pages had divergent inline error-code → user-copy switch
tables (with wording drift, including a stale "Settings → Integrations"
that should have been Admin). Consolidate into web/src/lib/styles/error-copy.json,
expose via web/src/lib/api/error-copy.ts (ERROR_COPY + copyForCode),
and refactor the three pages to import the helper.

Fixes the three-way drift; the only user-visible behavior change is
the quarantine page now correctly says "Admin → Integrations".

Sets up the JSON for the M7 Flutter client (#356) to consume the same
table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:01:46 -04:00
bvandeusen ea22807a3f refactor(web): extract design tokens to tokens.json source of truth
The Flutter client (#356) needs to consume the same FabledSword tokens
the web SPA uses. Move the data into a structured tokens.json file and
generate tokens.generated.css from it on every Vite build. The Flutter
side gets its own generator pointed at the same JSON.

No visual change — generated CSS matches the previous hand-written file.

Also installs @types/node devDependency: tsconfig already declared
"types": ["node"] but no Node builtins were referenced in TS until now,
so the missing types were latent. The new vite.config.ts plugin imports
node:child_process, surfacing the gap.
2026-05-02 14:55:46 -04:00
bvandeusen 261c2c4301 docs(m7): implementation plan for Flutter mobile foundation + first slice
22 tasks covering: shared design-tokens source-of-truth (web side),
error-copy JSON extraction, server /healthz min_client_version,
Flutter scaffold + Riverpod/dio/just_audio/audio_service stack, auth
flow with secure storage + version gate, library browse (home, artist,
album) with cards/rows synced to FabledSword tokens, likes with
optimistic toggle + rollback, audio handler with lock-screen +
notification controls, mini PlayerBar in shell + NowPlaying, error
surfacing (connection banner + 401-clears-session), Forgejo CI with
analyze + test + APK build + release attachment.
2026-05-02 14:26:25 -04:00
bvandeusen d16ea16d5d docs(m7): spec for Flutter mobile foundation + first slice
Lays the M7 #356 first-slice scope: project scaffold, theme system fed
from a shared FabledSword tokens.json, Riverpod + dio + just_audio
stack, auth flow against existing /api/auth/* (Bearer), library browse
(home/artist/album) with shell-route PlayerBar + NowPlaying, likes
included so layout settles. Tier 1 features beyond this slice (search,
discover, requests, settings, admin) get their own spec/plan cycles.
Desktop deferred to v1.1 as a Tauri-wrapped SvelteKit SPA, not Flutter
desktop.
2026-05-02 14:11:55 -04:00