Commit Graph

1096 Commits

Author SHA1 Message Date
bvandeusen c1fb2355c4 ci: migrate to ci-runners.md (container.image consumption)
Adopts the FabledRulebook ci-runners.md policy: workflows select
toolchains via container.image, not via runs-on labels. `runs-on`
stays as the scheduling handle only.

Workflows updated:
- test-go.yml: ci-go:1.26 on both `test` and `integration` jobs.
- test-web.yml: ci-go:1.26 (bundles Node + npm); the
  actions/setup-node@v4 step is removed.
- flutter.yml: ci-flutter:3.44 (Flutter 3.44 + Android + Java 25).
- release.yml: ci-go:1.26 (ships docker CLI + buildx).

Side effect: this unblocks the Go server deps bump (commit 6a62120)
which auto-bumped go.mod to `go 1.25.0` via x/crypto v0.51.0's
minimum — ci-go:1.26 satisfies it with headroom.

Adds ci-requirements.md at repo root per the ci-runners.md
"every project ships a requirements sheet" rule. Documents:
runtime images consumed, image deps used per workflow, per-job
installs (none), and a Notes section covering the integration
docker-socket dependency, the toolchain pin rationale, and the
in-app-update channel polling.

Tracked in local task #70.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:24:38 -04:00
bvandeusen 6a62120457 chore(deps): Go server bumps (x/crypto, pgx, migrate, pgerrcode)
Direct bumps:
- golang.org/x/crypto v0.35.0 → v0.51.0 (security backports;
  single highest-priority bump in the audit)
- github.com/jackc/pgx/v5 v5.7.4 → v5.9.2
- github.com/golang-migrate/migrate/v4 v4.18.2 → v4.19.1
- github.com/jackc/pgerrcode 2022-04-16 → 2025-09-07 (untagged
  pseudo refresh)

Side effects (good):
- `go mod tidy` dropped three transitives that migrate v4.18
  pulled in but v4.19 no longer needs: hashicorp/errwrap,
  hashicorp/go-multierror, go.uber.org/atomic.

Toolchain note:
- `go.mod` `go` directive auto-bumped 1.23.0 → 1.25.0 because
  x/crypto v0.51.0 declares Go 1.25 as its minimum. If the
  go-ci runner image isn't on Go 1.25+, CI will bounce on
  this; the runner image bump is operator infra (memory:
  project_forgejo_ci.md). Tracked in Fable #464.

Reference: Fable #464 + audit note #460.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:02:32 -04:00
bvandeusen 750bc69204 chore(deps): Tier A cross-ecosystem patch/minor sweep
No expected behavior changes; CI verifies. Tracked in Fable #463
under audit note #460.

Flutter (pubspec.yaml):
- flutter_secure_storage ^10.1.0 → ^10.2.0
- mocktail ^1.0.4 → ^1.0.5

Web (package.json + package-lock.json):
- @sveltejs/adapter-static ^3.0.6 → ^3.0.10
- @types/node ^25.6.0 → ^25.9.1
- autoprefixer ^10.4.20 → ^10.5.0
- postcss ^8.4.49 → ^8.5.15
- svelte-check ^4.0.5 → ^4.4.8

Tools (package.json + package-lock.json):
- sharp ^0.33.5 → ^0.34.5 (the lockfile diff is large because
  sharp ships pre-built binaries for many platform/arch combos;
  each version revs all those entries)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:58:24 -04:00
bvandeusen 66caee76fd chore(deps): drop unused cupertino_icons + tslib
Both verified unused at the source/config level (Fable #461 +
audit note #460):

- cupertino_icons: zero `CupertinoIcons` / cupertino imports in
  flutter_client/lib/. Pure `flutter create` template residue;
  the design system mandates Lucide.
- tslib: web/tsconfig.json does not set `importHelpers: true`,
  so TypeScript inlines helpers per-file. Declared peer with no
  runtime consumer.

`npm uninstall tslib --save-dev` updated package-lock.json
surgically (8 lines removed for the tslib entry only). No other
deps disturbed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:30:50 -04:00
bvandeusen 5c1a5f5e8b chore(flutter): bump version to 2026.5.19+13 for v2026.05.19.3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:47:28 -04:00
bvandeusen bb1ab3a2e3 test(web): un-skip discover/requests route tests — mock $app (#374)
Root cause: discover.test.ts and requests.test.ts were the only route
page tests NOT mocking $app/state (+ $app/navigation), so rendering
the +page.svelte pulled in SvelteKit's client runtime and threw
`TypeError: notifiable_store is not a function` at module load. They'd
been describe.skip'd AND hard-excluded in vitest.config.ts.

Fix mirrors every other route test: vi.hoisted pageState +
vi.mock('$app/state', () => pageUrlModule(state)) +
vi.mock('$app/navigation', () => ({ goto: vi.fn() })). Un-skip both
describe blocks; drop the vitest.config exclude. The web test job
now runs both suites again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:42:11 -04:00
bvandeusen 154626ae94 feat(player): stall watchdog + bounded retry + skip-to-cached (#66)
Reported: on poor coverage a track ends and the next (uncached) track
never starts — streams, hangs, no retry. Root cause: a buffering stall
emits NO error event so the onError path never fires and there was no
stall watchdog; even on a real error _handlePlaybackError immediately
skipped the literal-next (likely also-unreachable) source with no retry.

- _reconcileStallWatchdog: while playing+buffering, a 15s window; if
  buffered position hasn't advanced it's a dead stream → recover; if
  progressing, re-arm (slow-but-downloading is fine). Driven from
  _broadcastState like the idle/position reconcilers.
- _recoverPlayback unifies stall + onError: retry the SAME track once
  (skipToQueueItem rebuilds a fresh source/HTTP — a transient blip no
  longer loses it); on exhaustion, surface via the #58 SnackBar and
  skip to the next cached track, else pause (no thrashing through
  unreachable streams).
- per-track retry budget resets when a track reaches ready+playing.
- _handlePlaybackError now delegates into the unified path.

Core playback change — device-verify on a throttled connection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:14:25 -04:00
bvandeusen 1646b02ca2 refactor(flutter): drop legacy home snapshot path (#406)
The home screen renders solely from the per-item cached_home_index
path (proven on devices for many releases); the legacy snapshot stack
was dead weight.

- library_providers: remove homeProvider + _encodeHomeData +
  _albumToJson/_artistToJson/_trackToJson; drop now-unused dart:convert
  and models/home_data.dart imports
- metadata_prefetcher: re-point off homeIndexProvider/HomeIndex —
  pre-warm artistProvider from the rediscover/last-played artist
  sections (album/track tiles hydrate their own artist on render)
- live_events_dispatcher: homeProvider -> homeIndexProvider so live
  events still refresh the home screen
- db.dart: drop CachedHomeSnapshot (table class + @DriftDatabase
  entry); schemaVersion 10->11; from<3 createTable -> raw
  customStatement so the historical step compiles without the
  generated symbol; from<11 DROP TABLE cached_home_snapshot

No test references the removed symbols. db.g.dart regenerated by CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:04:43 -04:00
bvandeusen 1dc298c111 chore(flutter): bump version to 2026.5.19+12 for v2026.05.19.2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:47:45 -04:00
bvandeusen e605335339 fix(player): remove custom favorite MediaControl — it killed the notification
Device logcat (Pixel 6 Pro / Android 16) showed audio_service throwing
on every state broadcast:

  java.lang.IllegalArgumentException: You must specify an icon resource
  id to build a CustomAction
    at com.ryanheise.audioservice.AudioService...

The #57 MediaControl.custom favorite makes audio_service build a
PlaybackStateCompat.CustomAction whose icon id resolves to 0 on real
builds; the exception aborts the ENTIRE media notification, so nothing
posts to the tray or the watch (emulator tolerated it). Not a
permission / PathParser / FGS issue — POST_NOTIFICATIONS was verified
granted. Pre-#57 there was no CustomAction, matching the regression.

Remove the custom favorite control; the notification is rebuilt with
only the standard transport controls (audio_service ships their icons).
customAction handler / refreshFavoriteControl left as harmless no-ops
to minimise churn. Like/favorite remains in-app + lock screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:19:42 -04:00
bvandeusen 01a1ac148e chore(flutter): bump version to 2026.5.19+11 for v2026.05.19.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:08:13 -04:00
bvandeusen bc34d96329 fix(player): request POST_NOTIFICATIONS; auto-minimize player on teardown
Device-surfaced on physical Android 13+ (worked on emulator):

A) The media notification never appeared because the app never
   requested POST_NOTIFICATIONS at runtime — the manifest declares it
   and the foreground service is correct, but Android 13+ denies it by
   default until asked. Add permission_handler ^12.0.1 and request
   Permission.notification once at startup (post-first-frame,
   Platform.isAndroid-guarded; no-op on <13 / once decided).

B) When the #52 idle/dismiss teardown nulled mediaItem while the full
   NowPlayingScreen was open, it stranded the user on an empty
   "Nothing playing." Scaffold. Now post-frame maybePop() so it
   auto-minimizes (the mini bar is already gone).

pubspec.lock + db.g.dart regenerated by CI/build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:03:31 -04:00
bvandeusen eaddb2478a fix(ci): scope integration Postgres discovery to this job's network
`--filter name=integration` matched EVERY concurrent integration run's
Postgres service container. A dev push and the main-merge run overlap
on the shared act_runner daemon → 2 candidates → the "expected exactly
1" guard aborts (false failure; not a code defect).

Discover instead by intersecting networks: act_runner attaches the job
container and its service container to a shared per-job network, so
select the postgres that sits on a network this job container is also
on. The dev-compose container is skipped explicitly as before.

CI-only change; the released v2026.05.19.0 code is unaffected (a clean
re-run of the failed job passes — the failure was a concurrency race).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:50:04 -04:00
bvandeusen c681191b42 chore(flutter): bump version to 2026.5.19+10 for v2026.05.19.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:42:14 -04:00
bvandeusen 94871437dd feat(player): resume on media-button when session torn down (#448)
Fast-follow of #54. After the #52 idle/dismiss teardown, pressing play
on a headset / watch / lock screen did nothing (handler.play() was just
_player.play() with nothing loaded; the handler is Riverpod-agnostic).

- audio_handler: _resumeHook + setResumeHook(); play() is now async —
  when mediaItem == null and a hook is set it awaits the hook (which
  restores + plays) and returns, else _player.play()
- resume_controller: extract shared _loadAndRestore() (bool); _restore()
  keeps the paused launch path; new resumeFromMediaButton() restores
  then starts playback; start() registers it via setResumeHook

Recursion-safe (post-restore mediaItem != null so the re-play hits
_player.play()); no-op when nothing to resume / auth missing / a
session is already active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:31:40 -04:00
bvandeusen 359a072937 test(playlists): playlist_card finds LucideIcons.ellipsis_vertical
#60 swapped Icons.more_vert -> LucideIcons.ellipsis_vertical in
playlist_card.dart; the widget test still asserted the old Material
icon (find.byIcon(Icons.more_vert)) and failed. Update both finders
+ import flutter_lucide.

Note: like_button_test.dart still references Icons.favorite but is
skip:true (gated on Fable #399) so it compiles and doesn't run;
flagged as stale to update when that test is unskipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:50:10 -04:00
bvandeusen 835592f073 refactor(ui): Lucide migration unit 2 — Icons.* -> LucideIcons.* sweep (#60)
Mechanical sweep across 30 files: every Material Icons.* replaced with
the signed-off Lucide equivalent + a flutter_lucide import per file.
Zero Material Icons.* remain in lib/; no unused imports.

Judgment-call mappings: album->disc_3, library_music->library_big,
playlist_play->list_video, graphic_eq->audio_lines,
system_update->download, restore->archive_restore,
download_done->circle_check_big.

track_actions_sheet like menu row: collapsed `liked ? favorite :
favorite_border` to a single LucideIcons.heart (the row's Like/Unlike
text label conveys state). Icon-only LikeButton + the notification keep
the filled-vs-outline shape per the design decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:43:05 -04:00
bvandeusen 84f16c25f6 feat(ui): Lucide migration unit 1 — dependency + heart (#60)
Design system mandates Lucide, not Material. Foundation before the
mechanical Icons.* sweep:

- pubspec: add flutter_lucide ^1.11.0
- shared/widgets/lucide_heart.dart: LucideHeart renders the verified
  lucide-icons/lucide heart path as outline (stroke) or filled, via
  flutter_svg, tinted by color — Lucide ships no filled heart, so the
  liked state fills the same Lucide silhouette (user-chosen approach)
- like_button: use LucideHeart instead of Icons.favorite/_border
- notification drawables re-derived from the verbatim Lucide heart
  path (border = stroke, filled = fill); separators spaced for
  Android pathData

Unit 2 (mechanical Icons.* -> LucideIcons.* sweep) follows once this
is CI-green. pubspec.lock regenerated by CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:18:22 -04:00
bvandeusen 25ee54fca0 feat(player): surface playback errors via debounced SnackBar (#58)
_handlePlaybackError silently skipped a dead track (404 / decoder /
EOS / network drop) with only a debugPrint, hiding the signal that
tells a broken track from a flaky app.

- audio_handler: _playbackErrors broadcast stream; emit the failing
  track title (mediaItem.value?.title — correctly mapped post-#49)
  before the skip/pause
- playback_error_reporter (new): global scaffoldMessengerKey +
  reporter that buffers, 2s-debounces, and coalesces bursts into one
  SnackBar ("Couldn't play X — skipping" / "Skipped N unplayable
  tracks")
- app.dart: scaffoldMessengerKey on MaterialApp.router + postFrame read

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:39:58 -04:00
bvandeusen c659165218 fix(player): backtick doc-comment generics (analyze --fatal-infos)
`Future<dynamic>` in the customAction doc comment tripped
unintended_html_in_doc_comment (bare angle brackets read as HTML).
Wrap the code identifiers in backticks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:00:43 -04:00
bvandeusen 0d80a113fa feat(player): notification favorite via MediaControl.custom (6c)
Adds a heart action to the media notification implemented as a custom
control + customAction handler — NOT setRating, which is broken
upstream (audio_service #376: onSetRating never fires from a
notification tap) and previously blanked the Pixel Watch.

- res/drawable/ic_stat_favorite{,_border}.xml: white 24dp vector hearts
- audio_handler: favorite MediaControl.custom in _broadcastState
  (icon/label toggle by LikeBridge state; kept out of
  androidCompactActionIndices so compact/lock + Wear transport are
  unchanged); customAction override (Future<dynamic>, matches base)
  toggles the like then re-broadcasts; refreshFavoriteControl()
- player_provider: cascade refreshFavoriteControl into the likedIds
  listener so liking from TrackRow/kebab/SSE flips the notification heart

Reliable on phone notification + lock screen; Wear/Auto display of a
non-transport custom action is platform-dependent (not a bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:37:20 -04:00
bvandeusen 5d37616d52 feat(player): downscale + preload external cover art (8a)
AudioServiceConfig handed full-res album art to the notification /
lock screen / Wear. Add artDownscaleWidth/Height: 300 + preloadArtwork
so external surfaces get a smaller, faster, lower-memory cover with a
warm first paint.

6b (notification tap-to-open) dropped: androidNotificationClickStarts
Activity already defaults to true, so the tap foregrounds the app;
deep-linking to now-playing isn't a config knob and was judged
disproportionate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:04:51 -04:00
bvandeusen 77a4a55522 feat(player): periodic PlaybackState refresh for smooth external scrubber
_broadcastState only set updatePosition on transitions, so the lock-
screen / Wear / Android Auto scrubber jumped in chunks (the in-app bar
uses positionStream and was fine). Add _positionBroadcastTimer: a 1s
periodic PlaybackState re-broadcast while actively playing so
updateTime/updatePosition stay fresh and external surfaces interpolate
smoothly. Idempotent (driven from _broadcastState, which the tick
itself calls — guarded against pile-up), cancelled when not playing
and in stop(). 6a: the notification progress bar now advances since
MediaItem.duration was already set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:46 -04:00
bvandeusen 41dd2892e3 feat(player): resume last session on launch (#54)
The #52 teardown clears the session when idle/dismissed, so the
headset / lock-screen play button had nothing to resume and the user
lost their place.

- track.dart: toJson() (round-trips fromJson)
- db.dart: CachedResumeState single-row snapshot, schema 9->10 + migration
- audio_handler.dart: queuedTracks getter
- player_provider.dart: restoreQueue() — configure + setQueueFromTracks
  + seek, no play (restores PAUSED)
- resume_controller.dart: restores last snapshot paused on launch;
  persists {source,index,position_ms,tracks} debounced on track
  change / pause and immediately on app teardown; never clobbers the
  saved snapshot when the queue is empty so a teardown stays
  recoverable; restore gated on auth + no active session
- app.dart: wired into postFrame

db.g.dart regenerated by CI per project convention. Deferred fast-follow:
media-button-when-fully-stopped re-init (needs a configure()-injected
callback; tracked on #54).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:57:58 -04:00
bvandeusen c80dc0b306 fix(player): const AudioSessionConfiguration.music() (analyze --fatal-infos)
AudioSessionConfiguration.music() is a const constructor; the earlier
pre-emptive drop of `const` tripped prefer_const_constructors under
flutter analyze --fatal-infos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:47:41 -04:00
bvandeusen 57ce3d2d0a feat(player): audio focus, interruptions & becoming-noisy
The Flutter client had no audio_session integration, so it didn't pause
for phone calls / other media, didn't duck for navigation prompts, and
kept blasting the phone speaker when earbuds were unplugged.

Add audio_session ^0.2.3 and configure AudioSessionConfiguration.music()
in MinstrelAudioHandler (best-effort, fully try-caught):
- becomingNoisy -> pause (no speaker blast on unplug/BT drop)
- interruption begin: duck -> lower volume; pause/unknown -> pause,
  remembering whether we were actively playing
- interruption end: duck -> restore volume; pause -> resume only if we
  paused it and the session isn't torn down (guards the idle-teardown
  -during-long-call edge; re-init is resume-last-session territory);
  unknown -> no auto-resume

pubspec.lock regenerated by build/CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:43:52 -04:00
bvandeusen 905f05c120 feat(player): tear down MediaSession when idle/dismissed
Nothing drove the audio_service session to a terminal state and the
notification is configured ongoing, so the Wear tile / lock screen /
notification lingered on a stale paused track indefinitely.

- stop() override: pause, broadcast idle, clear queue/mediaItem, then
  super.stop() so the foreground service + notification (and watch tile)
  tear down; in-app mini bar collapses in lockstep.
- onTaskRemoved(): keep playing if audio is active (standard media
  behaviour), otherwise stop so a dismissed-while-paused app doesn't
  leave a stale tile.
- 5-minute idle timer armed while paused or on a finished queue,
  cancelled on resume / new queue, re-checked at fire time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:38:42 -04:00
bvandeusen e68e1b10a6 fix(player+lidarr): mini-player sync race; durable approve + dedup
player: setQueueFromTracks fast-starts a single source at player-index 0
while the full queue is broadcast, so the transient currentIndexStream→0
emission clobbered the correct mediaItem with queue.value[0] (the first
track). Mini bar / playlist marker pinned to the wrong track until a
later index event (~the "passive ~30s recovery"). Track a logical-index
base so the player→queue mapping stays correct during the fill window;
also fixes the latent forward-fill auto-advance off-by-base.

lidarr #50: approving no longer fails when Lidarr is down. Approve
records the decision durably first, then best-effort adds; the
reconciler idempotently (re)sends unconfirmed adds every tick until they
stick (new additive lidarr_add_confirmed_at; AddArtist/AddAlbum map
Lidarr's "already exists" 400 → ErrAlreadyExists). No failed-state or
expiry by design — Lidarr keeps trying, operator monitors.

lidarr #51: Create() is now idempotent — a non-terminal request for the
same MBID returns the existing row instead of inserting a duplicate.

Rewrites the obsolete LidarrUnreachable_503 test to assert the durable-
approve contract; threads a client factory into NewReconciler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:24:20 -04:00
bvandeusen b76ea66165 chore(flutter): bump version to 2026.5.18+9 for v2026.05.18.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:40:44 -04:00
bvandeusen 412b9e37eb test(coverart): no-MBID enrich settles 'none' (per canonical behavior)
Operator decision: the enricher is canonical. No MBID still runs the
provider chain (name-based providers — Deezer/Last.fm — resolve
without an MBID); if every provider returns ErrNotFound the row
settles cover/artist source 'none' at the current sources version
(re-eligible only when the registered provider set changes). It does
NOT skip-and-leave-NULL.

The two _NoMBID_LeavesNull tests predated the name-based providers
(0020 slice) and asserted the old skip→NULL contract. Updated:
- TestEnrichArtist_NoMBID_SettlesNone: stub now returns ErrNotFound
  (realistic MBID-only-provider-with-empty-MBID), expect source 'none'.
- TestEnrichAlbum_NoSidecarNoMBID_SettlesNone: empty registry →
  allWere404 stays true → expect 'none'.

Last failing cluster from the CI-integration initiative; suite should
now be fully green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:27:01 -04:00
bvandeusen a86b7a5e07 test(coverart): DrainsNullSourceOnly — stamp id2 'none' at current version
The test marked id2 'none' via SetAlbumCover, which (covers.sql:42)
does NOT set cover_art_sources_version. ListAlbumsMissingCover treats
'none' AND version != current as eligible, so id2 (version 0, current
1) was wrongly drained → processed=2. The comment's intent ("'none'
with current version → not drained") requires SetAlbumCoverWithVersion
(albums.sql) stamped with the live GetCurrentSourcesVersion — the same
value EnrichBatch compares against. Test-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:22:44 -04:00
bvandeusen 5e91efe695 test(coverart): fix registry-wipe + stale provider signature
Two distinct pre-existing test bugs in the last failing cluster:

1. newTestEnricher() called resetRegistryForTests() itself, wiping the
   fake providers callers Register() right before calling it (its own
   doc says callers register first and reconcile() picks them up). The
   ~8 TestEnrichArtist_* failures (source stayed NULL, no thumb
   written) all stem from reconcile() seeing an empty registry. Remove
   the internal reset; make every caller that lacked one own the
   registry lifecycle explicitly (resetRegistryForTests + t.Cleanup):
   SidecarFound, NoSidecarNoMBID, AlreadySidecar_NoOp,
   DrainsNullSourceOnly.

2. apiTestAlbumProvider.FetchAlbumCover had a stale signature
   (context, string) predating the AlbumRef refactor; it no longer
   satisfied coverart.AlbumCoverProvider, so the p.(AlbumCoverProvider)
   capability assertion failed and TestAdminListCoverSources got
   supports=[]. Fix the param to coverart.AlbumRef.

Test-only. (A1/A2 were correct; they unmasked these. Any residual
album-enricher semantics failures will be root-caused from the next
clean run, not bundled speculatively.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:05:36 -04:00
bvandeusen 47572b2e95 feat(flutter): Discover suggestions feed — web parity
The Flutter Discover screen was Lidarr-search-only; its empty state
showed a "Type to search" placeholder while web's /discover renders
the LB-derived out-of-library artist SuggestionFeed as its default
surface. Parity gap that slipped #356.

- ArtistSuggestion/SeedContribution model mirroring web types, with
  attributionText() matching web's "Because you liked/played X[, Y, and
  Z]." (Oxford comma, max 3).
- DiscoverApi.listSuggestions() → GET /api/discover/suggestions
  (image_url already resolved server-side from Lidarr, a7bea43).
- discover_screen: empty search box → suggestions feed (artist art +
  name + attribution + Request, reusing the existing createRequest +
  mutation-queue-replay flow with optimistic hide); typing → Lidarr
  search replaces; clearing → suggestions return. Mirrors web exactly.

Flutter-only; server endpoint unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:48:54 -04:00
bvandeusen 53a02322fb fix: A2 (relax art-source CHECK) + D + E integration residual
A2 — migration 0030: the albums/artists art `*_source` CHECK was a
fixed provider-ID allowlist fighting the extensible coverart.Register
registry (per-provider migration churn at 0016/0018/0020; rejected
test stub providers → ~11 enricher tests failed). Relax to "NULL or
non-empty"; the registry is the source of truth. Same brittleness
class as the #433 discovery-mix CHECK.

D — lidarr Approve resolves metadata/quality profiles (JSON arrays)
before the add; the test stubs returned {"id":1} for every path, so
ListMetadataProfiles failed to unmarshal → 500/Approve errors. Make
the lidarrrequests + admin_requests stubs path-aware (arrays for
/metadataprofile, /qualityprofile).

E:
- auth: requireUser (prelude.go) emitted code "auth_required"; the
  canonical code is "unauthenticated" (operator decision). Change the
  code; drop the now-dead "auth_required" web error-copy key
  ("unauthenticated" already has copy).
- playlists_system_test wrapped the real auth.RequireUser middleware
  but only injected withUser() context → 401. Like every other api
  handler test, drop the middleware and rely on requireUser().
- admin_users dup-username test seeded "test-existing" (seedUser
  prefixes) but POSTed "existing" → no collision; POST the prefixed
  name.
- me_timezone test decoded a top-level {"code"} but the envelope is
  {"error":{"code"}}; decode the nested shape.
- audit test asserted compact JSON; Postgres jsonb::text is spaced.
- put-lidarr-config tests predated the missing_defaults gate (correct
  handler behavior); supply the required defaults in the bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:22:30 -04:00
bvandeusen 75163ea483 test: correct the A regression + finish B residual (coverart boot, stale system_test)
A redo: the prior commit truncated cover_art_sources_meta, but
SettingsService.reconcile() only READS that singleton (seeded once by
migration 0018) and never recreates it → ~45 coverart/api tests hard-
failed "get current sources version: no rows". Correct reset: keep
cover_art_provider_settings in the truncate set (boot idempotently
re-UpsertProviderSettings), drop cover_art_sources_meta from it, and
instead `UPDATE cover_art_sources_meta SET current_version = 1` so the
row survives while cross-test version accumulation is cleared.

B residual (exposed once the play_events FK fix let these run):
- seedQuarantine used reason 'test-hide', invalid for the
  lidarr_quarantine_reason enum (bad_rip/wrong_file/wrong_tags/
  duplicate/other) → use 'other'.
- TestBuildSystemPlaylists_SufficientActivity predated #411/#352: the
  variant switch errored on the 5 new seedless mixes and capped
  track_count at 25. Accept deep_cuts/rediscover/new_for_you/
  on_this_day/first_listens as seedless; raise the cap to 100.

Test/test-harness only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:18 -04:00
bvandeusen d212621eaa test: fix 4 integration-suite root causes surfaced by CI (C+A+B+F)
Latent failures exposed now that integration tests run in CI (#339).
All test/test-harness only — no production code changes.

A (dbtest.ResetDB): also truncate cover_art_provider_settings +
cover_art_sources_meta. The monotonic source-version counter (seeded 1
by 0018) accumulated across internal/coverart tests → CurrentVersion=4
want 1, key-only-bump assertions, enricher source skips. SettingsService
re-seeds both at boot, so a truncated start is the correct fresh state.

B (playlists system_test.seedPlayEvent): inserted play_events with a
random session_id → play_events_session_id_fkey violation (7 tests).
Create the parent play_sessions row in the same statement (CTE).

C (similarity worker_integration_test.newTestWorker): built the client
with only BaseURL. Post-4fca0e6 similarity hits the Labs API via
LabsBaseURL, so an unset LabsBaseURL fell through to the real labs.api
and the stub never ran → 0 similarity rows. Set LabsBaseURL to the stub.

F (library scanner_test): NOT a flake — deterministic. Synthetic
ID3-only MP3s have no decodable duration; the scanner intentionally
won't skip duration_ms=0 rows (retries duration backfill), so every
re-scan reported Updated not Skipped. Seed duration_ms>0 before the
second scan so the mtime-based incremental-skip path under test is
actually exercised. Production scanner behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:32:42 -04:00
bvandeusen 5a048cbea2 fix(ci): serialize integration test packages (-p 1) to stop TRUNCATE deadlocks
First CI integration run proved the act_runner pattern works (service
discovery, migrate, exactly-one guard all functioned) but ~150 tests
failed with `dbtest.ResetDB truncate: deadlock detected (40P01)` plus
cascading FK/dup-key symptoms. Root cause: `go test ./...` runs package
binaries concurrently (default -p = NumCPU); every integration package
TRUNCATEs the single shared minstrel_test DB, so concurrent truncates
deadlock and half-seeded fixtures violate FKs. The documented local
invocation is `go test -p 1 ./...` for exactly this reason. Serialize
package execution in both the CI step and `make test-integration`.

Genuine (non-concurrency) failures will remain after this — never caught
before because integration tests never ran in CI. Triaged next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:22:06 -04:00
bvandeusen 760b4a7c6c feat(cli,ci): admin reset-password + migrate subcommands; test-DB isolation + CI integration job
#321 — `minstrel admin reset-password [-user admin] [-password X]`:
loads config, updates BOTH password_hash (bcrypt) and subsonic_password
(plaintext, for Subsonic t+s) so neither auth path is left stale;
generates+prints a strong password when -password is omitted. Recovers
a locked-out operator without DB surgery. Subcommand dispatch added to
main.go (os.Args switch before flag-parse; server path untouched) plus
a `minstrel migrate` subcommand exposing db.Migrate standalone.

#339 — integration tests no longer truncate the dev DB:
- deploy/initdb creates minstrel_test on a fresh compose volume;
  `make test-integration` ensures it idempotently and points
  MINSTREL_TEST_DATABASE_URL at minstrel_test.
- docker-compose.yml + README updated.

CI integration job (test-go.yml): the prior workflow only ran
`go test -short -race` with no DB, so the entire integration suite
silently t.Skip'd — "CI green" never covered API/db/scanner. New
`integration` job runs the full `go test -race` against an ephemeral
Postgres service, using the act_runner shared-daemon pattern: no
published ports, discover the service container by job-name filter via
the docker socket, reach it by bridge IP, hard exactly-one assertion +
dev-compose-name reject (a wrong target would truncate real data),
TCP-wait, `minstrel migrate`, then test. Fast `test` job kept as the
quick gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:36:36 -04:00
bvandeusen a7bea43a13 feat(discover): on-demand Lidarr artist art for suggestions
Out-of-library suggestion artists (artist_similarity_unmatched rows)
have no local art row, so the Discover card always showed a
placeholder. Resolve art on-demand from Lidarr's artist lookup,
matched by MBID (foreignArtistId == candidate_mbid), and pass the
remote image URL straight through — no caching (a suggestion may
never be viewed; the browser fetches the URL directly).

- suggestionView gains image_url (omitempty); handler resolves it via
  bounded-concurrency Lidarr LookupArtist, best-effort, never fails
  the request.
- Lidarr is the sole source: disabled / unreachable / no-match →
  empty → existing placeholder. (No inline TheAudioDB fallback — it's
  externally rate-limited and there's no cache to amortize it.)
- web: ArtistSuggestion.image_url?; SuggestionFeed passes it to
  DiscoverResultCard (already supports imageUrl).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:16:56 -04:00
bvandeusen 9ffe33a6f2 chore(flutter): bump version to 2026.5.16+8 for v2026.05.16.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:58:19 -04:00
bvandeusen 3b142e5332 test(server): drop removed coverArtBackfillCap arg from New() calls
Follows 005965d (#388), which removed the coverArtBackfillCap param
from server.New. server_test.go is in package server so it calls New()
unqualified — missed by the signature-caller grep. Updated all 6
positional calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:45:24 -04:00
bvandeusen 005965d6de feat(coverart): #388 remove global cover-art backfill cap
Operator feedback (2026-05-09): the 500-album cap meant cover art rolled
in over many nightly runs even when local sources (sidecar/embedded)
could finish in minutes. Remove the global cap; rely on the existing
per-provider HTTP throttle (coverart httpClient MinInterval) so local
art is disk-speed and remote providers stay TOS-friendly.

- enricher.go / artist_enricher.go: EnrichBatch, EnrichArtistBatch,
  EnrichRetryMissing now treat limit<0 as unbounded (0 still = stage
  disabled). The cap was a SQL LIMIT; unbounded uses max int32.
- main.go: RunScanConfig EnrichCap/ArtistEnrichCap = -1 (unbounded).
- Drop LibraryConfig.CoverArtBackfillCap + the
  MINSTREL_LIBRARY_COVERART_BACKFILL_CAP env var.
- Drop the now-dead coverBackfillCap param threaded through
  server.New + api.Mount + the handlers struct.
- Admin bulk refetch (/api/admin/covers/refetch-missing) now drains
  unbounded; response {queued:int} → {started:bool} (the count is
  unknowable synchronously for a fire-and-forget drain). Web copy +
  client type + Go/web tests updated to match.

No doc refs existed (config.example.yaml / docker-compose / README
never documented the env var).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:41:21 -04:00
bvandeusen 4fca0e66cb fix(listenbrainz): similarity hits Labs API, not the 404'ing main API
Root cause of zero LB recommendations (every similar-recordings AND
similar-artists call returned HTTP 404, worker logs):

- Wrong host/path: client called
  api.listenbrainz.org/1/explore/similar-{recordings,artists}/{mbid}.
  /explore/... is a WEBSITE route, not an API endpoint — it 308s then
  404s. Similarity datasets live on the separate Labs API.
- Invalid algorithm: the hardcoded
  session_…_session_30_…_limit_100_filter_True_… is not a permitted
  Labs enum member (400s) regardless of host.

Verified against the live Labs API:
  GET labs.api.listenbrainz.org/similar-recordings/json
      ?recording_mbids=<mbid>&algorithm=<algo>
  GET labs.api.listenbrainz.org/similar-artists/json
      ?artist_mbids=<mbid>&algorithm=<algo>
  algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30
  → 200 for both. Response field names (recording_mbid/artist_mbid/
  name/score) already match the existing structs — parsing unchanged.

- Add defaultLabsBaseURL + Client.LabsBaseURL (separate from the main
  BaseURL; scrobble submission still uses api.listenbrainz.org).
- Drop the count/limit query param — result size is encoded in the
  algorithm name (limit_50); caller still applies its own top-K.
- Tests: newTestClient sets LabsBaseURL; the two *_LimitParamSet tests
  become *_MbidParamSet (assert the Labs path + mbid query param).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:50:32 -04:00
bvandeusen 4d2aebe3ed test(similarity): update default-batch assertion 5→25
Follows ca1bc5a, which raised the worker batch default. The defaults
test pinned the old value; align it with the intended new default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:51:22 -04:00
bvandeusen ca1bc5af62 perf(similarity): worker batch 5→25; track-MBID backfill uncapped
- similarity.Worker batch 5→25 (tracks AND artists per 1h tick). At
  5/h a freshly-scrobbled library took days to build a usable
  similarity pool; 25/h converges in hours, still well under
  ListenBrainz rate limits (429 aborts the tick).
- scanrun Stage 2b track backfill now runs unbounded (-1) instead of
  reusing the album backfill's 5000 staged cap. It's a one-time
  whole-library heal with no progress UI; a cap just left tracks.mbid
  partially NULL (3634/18056 after one pass) until several future
  scans caught up, re-reading untagged files each time. One uncapped
  pass converges; later scans only re-read the remaining NULL rows.
  Album backfill keeps its 5000 cap (it has staged scan_runs UX).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:24:45 -04:00
bvandeusen e2432caa65 fix(library): extract recording MBID → tracks.mbid; unblock LB similarity
tracks.mbid was 100% NULL: the scanner only extracted album + artist
MBIDs, never the recording MBID. The ListenBrainz similarity worker is
gated on tracks.mbid IS NOT NULL, so track_similarity could never
populate — starving For-You/radio's strongest candidate source
(lb_similar) on every library.

- mbids.go: add extractRecordingMBID (mbz.Recording / Picard
  musicbrainz_recordingid). Separate fn so extractMBIDs' signature +
  unit tests stay untouched.
- scanner.go: persist recording MBID via UpsertTrack (heals on the
  file_path conflict, so re-scans backfill for free).
- BackfillTrackMBIDs: one-shot pass mirroring BackfillMBIDs, wired as
  scan Stage 2b (idempotent via SetTrackMbidIfNull, gated by
  BackfillCap, log-only progress).
- migration 0029: tracks_mbid_unique (0002, written when the column
  was always NULL) wrongly assumes one MBID == one track. A recording
  appears on multiple releases, so rows legitimately share a recording
  MBID. Replace with a non-unique partial index. Zero-risk: column is
  100% NULL at migration time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:49:38 -04:00
bvandeusen 2e7b81fdfe fix(playlists): robust For-You seed + deep fill; young-library mix fallbacks
For-You silently vanished after ~7 days of not listening (seed query
required a non-skip play in the last 7 days) and capped at ~40 on
self-hosted libraries with no ListenBrainz similarity data.

- PickTopPlayedTracksForUser: tiered seed — last 30d top plays, else
  all-time top plays, else liked tracks. For-You only disappears now
  if the account has zero plays AND zero likes.
- produceForYou uses deeper candidate source limits (raised random/
  tag/similar K) so it reaches ~100 even with empty lb_similar /
  similar_artists; richer when LB enrichment is present.
- Rediscover: tiered — 6-month-dormant, else ≥5-play 30-day-dormant.
- On This Day: floor 60→30 days, window ±7→±10 doy; still skips
  cleanly (no rows → no playlist) on insufficient history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:48:20 -04:00
bvandeusen 1e2c486356 fix(playlists): allow the 5 discovery-mix variants in DB constraints
HOTFIX for v2026.05.15.0. R3 added deep_cuts/rediscover/new_for_you/
on_this_day/first_listens producers + registry entries but not their
system_variant values to the playlists_kind_variant_consistent /
playlists_seed_consistent CHECK constraints (0021's whitelist).
insertSystemPlaylist for any new mix → SQLSTATE 23514, and since
BuildSystemPlaylists is one all-or-nothing txn that aborts the
ENTIRE build — For-You/Discover refresh 500s and the daily lazy
build fails too. System playlists are fully broken in prod.

Migration 0028 drops + re-adds both constraints with all five new
seedless variants (mirrors the 0021 drop-and-readd pattern).
CHECK-only — sqlc/dbq unaffected (regen produced no drift).

Bumps to 2026.5.15+7 for the v2026.05.15.1 patch release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:39:21 -04:00
bvandeusen 0c2b86e736 chore(flutter): bump version to 2026.5.15+6 for v2026.05.15.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:15:10 -04:00
bvandeusen ce36760819 feat(playlists): #417 — "Refreshed …" subtitle on system tiles
Closes the last buildable item of the #411 system-playlists-v2
umbrella. System playlists atomic-replace on rebuild, so created_at
(already on the wire — no server change) is the last-rotated time.
Surface it as a small tile subtitle so users see how fresh a mix
is: "Refreshed just now / today / yesterday / N days ago / Mon D".

- web PlaylistCard: refreshedLabel() + a muted footer line, shown
  only when system_variant != null. Unparseable/empty timestamp →
  suppressed (web test fixtures use created_at:'' so no test churn).
- flutter PlaylistCard: mirrored _refreshedLabel() + subtitle under
  the system badge for isSystem playlists.

Friendly wording deliberately distinct from HistoryRow's "m/h ago";
per-surface helper per the project's existing relative-time
convention. CI-pending; closes with the umbrella on device-check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:03:41 -04:00