diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 11a0e375..29b9e11c 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -14,6 +14,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" @@ -24,6 +25,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/server" "git.fabledsword.com/bvandeusen/minstrel/internal/similarity" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) func main() { @@ -146,9 +148,38 @@ func run() error { // import requests and reconciles them against the library. Short-circuits // to no-op when lidarr_config.enabled = false. lidarrCfg := lidarrconfig.New(pool) - lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr")) + // Live-event bus shared between SSE subscribers (api.Mount) and + // background workers that publish (reconciler today; scanner later). + // Constructed before any service that publishes so they all share the + // same instance. + bus := eventbus.New() + // Scan-run lifecycle events use a package-level setter rather than + // threading the bus through RunScan + TryStartScan + the Scheduler + // + every test caller. Per-process singleton, set once at startup. + library.SetEventBus(bus) + lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr"), bus) go lidarrReconciler.Run(ctx) + // library_changes compactor (#357 follow-up). Daily tick; deletes + // rows older than the configured retention so the change-log table + // doesn't grow unbounded. Clients that drop offline longer than + // retention hit the /api/library/sync 410 fallback and resync. + libraryChangesCompactor := syncpkg.NewCompactor(pool, logger.With("component", "library_changes_compactor")) + go libraryChangesCompactor.Run(ctx) + + // Per-user system-playlist scheduler (#392 Half B). Fires each + // active user's daily build at 03:00 in their stored timezone. + // Replaces the 24h-anchored cron loop (removed in the next commit + // of this arc). + playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir) + if err != nil { + return fmt.Errorf("init playlist scheduler: %w", err) + } + if err := playlistScheduler.Start(ctx); err != nil { + return fmt.Errorf("start playlist scheduler: %w", err) + } + defer playlistScheduler.Stop() + // Ensure DataDir exists before any service tries to write into it. // Fatal on failure: a non-writable data_dir silently breaks every // downstream cache (playlist covers, artist art, album-cover fallback) @@ -173,7 +204,8 @@ func run() error { srv := server.New(logger, pool, scanner, subsonic.Config{ AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler) - playlists.StartSystemPlaylistCron(ctx, pool, logger.With("component", "system_playlist_cron"), cfg.Storage.DataDir) + srv.Bus = bus + srv.PlaylistScheduler = playlistScheduler httpServer := &http.Server{ Addr: cfg.Server.Address, Handler: srv.Router(), diff --git a/flutter_client/lib/admin/admin_quarantine_screen.dart b/flutter_client/lib/admin/admin_quarantine_screen.dart index d9c55a9d..be77580a 100644 --- a/flutter_client/lib/admin/admin_quarantine_screen.dart +++ b/flutter_client/lib/admin/admin_quarantine_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../shared/live_events_provider.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'admin_providers.dart'; @@ -12,6 +13,15 @@ class AdminQuarantineScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + // #402 wire-up: any quarantine event (flag from a user / admin + // resolve / file delete / lidarr delete) refreshes the admin queue. + ref.listen>(liveEventsProvider, (_, next) { + final e = next.asData?.value; + if (e == null) return; + if (e.kind.startsWith('quarantine.')) { + ref.invalidate(adminQuarantineProvider); + } + }); final items = ref.watch(adminQuarantineProvider); return Scaffold( backgroundColor: fs.obsidian, diff --git a/flutter_client/lib/admin/admin_requests_screen.dart b/flutter_client/lib/admin/admin_requests_screen.dart index b0faecf6..5063265e 100644 --- a/flutter_client/lib/admin/admin_requests_screen.dart +++ b/flutter_client/lib/admin/admin_requests_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/admin_user.dart'; +import '../shared/live_events_provider.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'admin_providers.dart'; @@ -13,6 +14,15 @@ class AdminRequestsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + // #402 wire-up: invalidate the admin requests list when a user + // creates/cancels/admin approves/rejects/reconciler completes — + // request.status_changed covers all of them. + ref.listen>(liveEventsProvider, (_, next) { + final e = next.asData?.value; + if (e?.kind == 'request.status_changed') { + ref.invalidate(adminRequestsProvider); + } + }); final requests = ref.watch(adminRequestsProvider); // Best-effort lookup for requester usernames. If the users provider // hasn't loaded yet, valueOrNull is null and rows fall back to the diff --git a/flutter_client/lib/api/endpoints/library.dart b/flutter_client/lib/api/endpoints/library.dart index 8fab0c23..6d38b154 100644 --- a/flutter_client/lib/api/endpoints/library.dart +++ b/flutter_client/lib/api/endpoints/library.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; import '../../models/album.dart'; import '../../models/artist.dart'; import '../../models/home_data.dart'; +import '../../models/home_index.dart'; import '../../models/track.dart'; /// LibraryApi wraps the server's native /api/* library surface. @@ -27,6 +28,24 @@ class LibraryApi { return HomeData.fromJson(r.data ?? const {}); } + /// GET /api/home/index — per-item rendering variant. Returns just IDs + /// per section; client hydrates each tile via the per-entity + /// endpoints. ~10× smaller than /api/home on populated libraries so + /// the cold-visit round-trip is correspondingly short. + Future getHomeIndex() async { + final r = await _dio.get>('/api/home/index'); + return HomeIndex.fromJson(r.data ?? const {}); + } + + /// GET /api/tracks/{id}. Returns the canonical TrackRef. Used by + /// the HydrationQueue to populate cached_tracks on a per-tile miss + /// — the existing endpoint already joins album + artist so the + /// response carries everything TrackRef needs. + Future getTrack(String id) async { + final r = await _dio.get>('/api/tracks/$id'); + return TrackRef.fromJson(r.data ?? const {}); + } + /// GET /api/artists/{id}. Server returns ArtistDetail which embeds /// ArtistRef inline; ArtistRef.fromJson already reads only the fields /// it cares about, so passing the whole body is correct. diff --git a/flutter_client/lib/api/endpoints/me.dart b/flutter_client/lib/api/endpoints/me.dart index 3d5656ab..9d867c2e 100644 --- a/flutter_client/lib/api/endpoints/me.dart +++ b/flutter_client/lib/api/endpoints/me.dart @@ -38,4 +38,12 @@ class MeApi { ); return SystemPlaylistsStatus.fromJson(r.data ?? const {}); } + + /// PUT /api/me/timezone — submit the device's IANA timezone. The + /// scheduler uses this to fire the user's daily playlist build at + /// 03:00 in their local time. See AuthController._sendTimezoneIfStale + /// for the weekly cadence trigger. + Future putTimezone(String timezone) async { + await _dio.put('/api/me/timezone', data: {'timezone': timezone}); + } } diff --git a/flutter_client/lib/app.dart b/flutter_client/lib/app.dart index 58073049..78192adf 100644 --- a/flutter_client/lib/app.dart +++ b/flutter_client/lib/app.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'cache/metadata_prefetcher.dart'; import 'cache/prefetcher.dart'; import 'cache/sync_controller.dart'; +import 'shared/live_events_dispatcher.dart'; import 'shared/routing.dart'; import 'theme/theme_data.dart'; import 'theme/theme_mode_provider.dart'; @@ -33,6 +34,11 @@ class _MinstrelAppState extends ConsumerState { // each section so subsequent taps are drift hits, not network // round trips. ref.read(metadataPrefetcherProvider); + // Live events (#392): subscribes to /api/events/stream and + // invalidates publicly-scoped providers when relevant events + // arrive. Also installs an AppLifecycleState observer for + // resume-time defensive invalidation. + ref.read(liveEventsDispatcherProvider); }); } diff --git a/flutter_client/lib/auth/auth_provider.dart b/flutter_client/lib/auth/auth_provider.dart index af8ca625..4ab61793 100644 --- a/flutter_client/lib/auth/auth_provider.dart +++ b/flutter_client/lib/auth/auth_provider.dart @@ -2,12 +2,17 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; +import '../api/endpoints/me.dart'; +import '../library/library_providers.dart' show dioProvider; import '../models/user.dart'; -const _kServerUrl = 'server_url'; -const _kSessionToken = 'session_token'; -const _kCurrentUser = 'current_user'; +const _kServerUrl = 'server_url'; +const _kSessionToken = 'session_token'; +const _kCurrentUser = 'current_user'; +const _kTzLastSentAt = 'tz_last_sent_at'; +const _weeklyMs = 7 * 24 * 60 * 60 * 1000; final secureStorageProvider = Provider( (ref) => const FlutterSecureStorage(), @@ -29,7 +34,12 @@ class AuthController extends AsyncNotifier { _storage = ref.watch(secureStorageProvider); final raw = await _storage.read(key: _kCurrentUser); if (raw == null) return null; - return User.fromJson(jsonDecode(raw) as Map); + final user = User.fromJson(jsonDecode(raw) as Map); + // Fire-and-forget timezone send on app start with an existing + // session — no-op when last_sent_at is fresh. + // ignore: unawaited_futures + _sendTimezoneIfStale(); + return user; } Future setServerUrl(String url) async { @@ -42,6 +52,8 @@ class AuthController extends AsyncNotifier { await _storage.write(key: _kCurrentUser, value: userJson); ref.invalidate(sessionTokenProvider); state = AsyncData(User.fromJson(jsonDecode(userJson) as Map)); + // ignore: unawaited_futures + _sendTimezoneIfStale(); } Future clearSession() async { @@ -50,6 +62,27 @@ class AuthController extends AsyncNotifier { ref.invalidate(sessionTokenProvider); state = const AsyncData(null); } + + /// Sends the device's current IANA timezone to PUT /api/me/timezone + /// when the last successful send was >7 days ago (or never). + /// Cadence persists in flutter_secure_storage so it survives app + /// restarts. Failures are swallowed: the server keeps its previous + /// value (or 'UTC' default) until the next attempt. + Future _sendTimezoneIfStale() async { + try { + final lastStr = await _storage.read(key: _kTzLastSentAt); + final lastMs = lastStr == null ? 0 : int.tryParse(lastStr) ?? 0; + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (nowMs - lastMs < _weeklyMs) return; + final tz = await FlutterTimezone.getLocalTimezone(); + if (tz.isEmpty) return; + final dio = await ref.read(dioProvider.future); + await MeApi(dio).putTimezone(tz); + await _storage.write(key: _kTzLastSentAt, value: nowMs.toString()); + } catch (_) { + // Non-fatal — server falls back to UTC or last-known value. + } + } } final authControllerProvider = diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart index 15df5372..d2227677 100644 --- a/flutter_client/lib/cache/db.dart +++ b/flutter_client/lib/cache/db.dart @@ -115,6 +115,77 @@ class SyncMetadata extends Table { Set get primaryKey => {id}; } +/// Single-row cache of the /api/home response (#357 follow-up). The +/// home screen is the first thing the user sees on app open; storing +/// the last successful HomeData as JSON lets us yield it immediately +/// before the REST round-trip resolves, eliminating the cold-start +/// blank on slow / remote connections. Schema 3+. +class CachedHomeSnapshot extends Table { + IntColumn get id => integer().withDefault(const Constant(1))(); + TextColumn get json => text()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + @override + Set get primaryKey => {id}; +} + +/// Single-row cache of the /api/me/history response. Mirrors the +/// CachedHomeSnapshot pattern: opening the History tab in the Library +/// screen yields the last-known page immediately while a fresh REST +/// pull lands underneath via SWR. Also makes basic offline scrollback +/// possible — the last fetched page survives both app restart and +/// loss of connectivity. Schema 4+. +class CachedHistorySnapshot extends Table { + IntColumn get id => integer().withDefault(const Constant(1))(); + TextColumn get json => text()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + @override + Set get primaryKey => {id}; +} + +/// Section→position→entity-id index for the home screen, populated +/// from the per-item discovery endpoint `/api/home/index`. Each row +/// pins one tile slot to an entity; the actual entity data lives in +/// cached_albums / cached_artists / cached_tracks. The home screen +/// reads this table to know the layout, then hydrates each tile +/// against the entity tables (per-item rendering). Schema 6+. +class CachedHomeIndex extends Table { + /// One of: 'recently_added_albums', 'rediscover_albums', + /// 'rediscover_artists', 'most_played_tracks', 'last_played_artists'. + /// Mirrors the keys /api/home and /api/home/index emit. + TextColumn get section => text()(); + IntColumn get position => integer()(); + /// One of: 'album', 'artist', 'track'. Used to dispatch hydration + /// to the right per-entity endpoint. + TextColumn get entityType => text()(); + TextColumn get entityId => text()(); + DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); + @override + Set get primaryKey => {section, position}; +} + +/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of +/// /api/quarantine/mine — fully denormalized (track + album + artist +/// fields inline) because the Hidden tab renders straight from this row +/// without joining other tables. Columnar (vs JSON blob) so flag/unflag +/// can do row-level INSERT/DELETE; the drift watch() emission feeds +/// MyQuarantineController state directly. Schema 5+. +class CachedQuarantineMine extends Table { + TextColumn get trackId => text()(); + TextColumn get reason => text()(); + TextColumn get notes => text().nullable()(); + TextColumn get createdAt => text()(); + TextColumn get trackTitle => text()(); + IntColumn get trackDurationMs => integer().withDefault(const Constant(0))(); + TextColumn get albumId => text()(); + TextColumn get albumTitle => text()(); + TextColumn get albumCoverArtPath => text().nullable()(); + TextColumn get artistId => text()(); + TextColumn get artistName => text()(); + DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); + @override + Set get primaryKey => {trackId}; +} + enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental } @DriftDatabase(tables: [ @@ -126,12 +197,16 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental } CachedPlaylistTracks, AudioCacheIndex, SyncMetadata, + CachedHomeSnapshot, + CachedHistorySnapshot, + CachedQuarantineMine, + CachedHomeIndex, ]) class AppDb extends _$AppDb { AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); @override - int get schemaVersion => 2; + int get schemaVersion => 6; @override MigrationStrategy get migration => MigrationStrategy( @@ -147,6 +222,31 @@ class AppDb extends _$AppDb { // pre-existing rows until they happen to change server-side. await customStatement('UPDATE sync_metadata SET cursor = 0'); } + if (from < 3) { + // Schema 3: cached_home_snapshot for #357 drift-first + // home page. No existing rows to migrate — table starts + // empty; the first /api/home fetch populates it. + await m.createTable(cachedHomeSnapshot); + } + if (from < 4) { + // Schema 4: cached_history_snapshot for drift-first + // History tab. Same pattern as cached_home_snapshot — + // empty on upgrade; first /api/me/history fetch populates. + await m.createTable(cachedHistorySnapshot); + } + if (from < 5) { + // Schema 5: cached_quarantine_mine for drift-first Hidden + // tab. Empty on upgrade; first /api/quarantine/mine fetch + // populates. Optimistic flag/unflag also writes here. + await m.createTable(cachedQuarantineMine); + } + if (from < 6) { + // Schema 6: cached_home_index for per-item rendering. + // Empty on upgrade; first /api/home/index fetch populates. + // cached_home_snapshot stays in place for now — older + // builds still read from it as a fallback path. + await m.createTable(cachedHomeIndex); + } }, ); } diff --git a/flutter_client/lib/cache/hydration_queue.dart b/flutter_client/lib/cache/hydration_queue.dart new file mode 100644 index 00000000..a16c614d --- /dev/null +++ b/flutter_client/lib/cache/hydration_queue.dart @@ -0,0 +1,139 @@ +// Per-item hydration coordinator for the home/playlist/liked surfaces. +// +// Tile widgets are reactive to their per-entity drift row. On first +// build, if drift has no row for the requested id, the tile asks this +// queue to hydrate it. The queue dispatches one /api//:id +// request, writes the result to drift, and the drift watch() on the +// tile re-emits with the populated row. +// +// Concurrency cap: HydrationQueue limits the number of in-flight +// requests so a 50-tile home screen doesn't fire 50 parallel /api/ +// calls and stall the auth-token-bound dio pool. In-flight dedup +// (keyed by `:`) ensures a single hydration when multiple +// tiles share an entity (rare on home, common when several screens +// reference the same album). +// +// Failures are swallowed — a single failed hydration leaves the tile +// in skeleton state. A future retry-on-visit pass can layer on top +// without changing the queue's contract. + +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../cache/adapters.dart'; +import '../cache/audio_cache_manager.dart' show appDbProvider; +import '../library/library_providers.dart' show libraryApiProvider; + +/// Bounded-concurrency request queue for per-entity hydration. One +/// instance per Riverpod scope (provider singleton below). +class HydrationQueue { + HydrationQueue(this._ref); + final Ref _ref; + + /// Concurrent slot count. 4 keeps the dio token pool from saturating + /// while still pulling tiles down faster than a serial loop. Bump + /// if profiling shows the queue idle while the network is healthy. + static const _maxConcurrent = 4; + + final Queue<_HydrationRequest> _pending = Queue(); + final Set _inFlightKeys = {}; + int _inFlight = 0; + + /// Request hydration for (entityType, entityId). Idempotent — second + /// call for the same key while the first is queued or in flight is + /// dropped, so multiple tile rebuilds during a fast scroll don't + /// inflate the queue. + void enqueue({required String entityType, required String entityId}) { + if (entityId.isEmpty) return; + final key = '$entityType:$entityId'; + if (_inFlightKeys.contains(key)) return; + if (_pending.any((r) => r.key == key)) return; + _pending.add(_HydrationRequest( + entityType: entityType, entityId: entityId, key: key)); + _pump(); + } + + void _pump() { + while (_inFlight < _maxConcurrent && _pending.isNotEmpty) { + final req = _pending.removeFirst(); + _inFlightKeys.add(req.key); + _inFlight++; + // Unawaited on purpose — _pump returns immediately so further + // enqueues can fill remaining slots while this one runs. + unawaited(_execute(req).whenComplete(() { + _inFlight--; + _inFlightKeys.remove(req.key); + _pump(); + })); + } + } + + Future _execute(_HydrationRequest req) async { + try { + switch (req.entityType) { + case 'album': + await _hydrateAlbum(req.entityId); + case 'artist': + await _hydrateArtist(req.entityId); + case 'track': + await _hydrateTrack(req.entityId); + } + } catch (_) { + // Swallow — tile stays in skeleton. A retry-on-visit pass can + // layer on top later without changing this contract. + } + } + + Future _hydrateAlbum(String id) async { + final api = await _ref.read(libraryApiProvider.future); + final result = await api.getAlbum(id); + final db = _ref.read(appDbProvider); + // Album + tracks come bundled in the same response; persist both + // so opening the album detail later is also a drift hit. + await db.transaction(() async { + await db + .into(db.cachedAlbums) + .insertOnConflictUpdate(result.album.toDrift()); + if (result.tracks.isNotEmpty) { + await db.batch((b) { + b.insertAllOnConflictUpdate( + db.cachedTracks, result.tracks.map((t) => t.toDrift()).toList()); + }); + } + }); + } + + Future _hydrateArtist(String id) async { + final api = await _ref.read(libraryApiProvider.future); + final fresh = await api.getArtist(id); + final db = _ref.read(appDbProvider); + await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift()); + } + + Future _hydrateTrack(String id) async { + final api = await _ref.read(libraryApiProvider.future); + final fresh = await api.getTrack(id); + final db = _ref.read(appDbProvider); + await db.into(db.cachedTracks).insertOnConflictUpdate(fresh.toDrift()); + } +} + +class _HydrationRequest { + _HydrationRequest({ + required this.entityType, + required this.entityId, + required this.key, + }); + final String entityType; + final String entityId; + final String key; +} + +/// Singleton queue scoped to the Riverpod container. Kept as a plain +/// Provider (not StateProvider) since the queue's internal state is +/// pump-driven, not reactively observed. +final hydrationQueueProvider = Provider((ref) { + return HydrationQueue(ref); +}); diff --git a/flutter_client/lib/cache/sync_controller.dart b/flutter_client/lib/cache/sync_controller.dart index 1cc3cb2f..b4f1f91a 100644 --- a/flutter_client/lib/cache/sync_controller.dart +++ b/flutter_client/lib/cache/sync_controller.dart @@ -1,6 +1,10 @@ +import 'dart:async'; + import 'package:drift/drift.dart' as drift; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../auth/auth_provider.dart' show serverUrlProvider, sessionTokenProvider; import '../library/library_providers.dart' show dioProvider; import 'audio_cache_manager.dart' show appDbProvider; import 'db.dart'; @@ -75,6 +79,14 @@ class SyncController extends AsyncNotifier { var upsertCount = 0; var deleteCount = 0; + // IDs of upserted entities whose covers we'll pre-warm after the + // transaction commits. Filled inside the loops; consumed by + // _prewarmCovers fire-and-forget below. Artist covers are + // server-derived from "most-recent album" and aren't + // reconstructible client-side — album pre-warm already covers an + // artist's primary visual via its detail-page header. + final albumCoverIds = []; + final playlistCoverIds = []; await db.transaction(() async { // ---- Upserts ---- @@ -85,9 +97,12 @@ class SyncController extends AsyncNotifier { upsertCount++; } for (final a in (upserts['album'] as List? ?? const [])) { + final m = a as Map; await db.into(db.cachedAlbums).insertOnConflictUpdate( - _albumFromJson(a as Map), + _albumFromJson(m), ); + final id = m['id']; + if (id is String && id.isNotEmpty) albumCoverIds.add(id); upsertCount++; } for (final t in (upserts['track'] as List? ?? const [])) { @@ -130,9 +145,12 @@ class SyncController extends AsyncNotifier { upsertCount++; } for (final p in (upserts['playlist'] as List? ?? const [])) { + final m = p as Map; await db.into(db.cachedPlaylists).insertOnConflictUpdate( - _playlistFromJson(p as Map), + _playlistFromJson(m), ); + final id = m['id']; + if (id is String && id.isNotEmpty) playlistCoverIds.add(id); upsertCount++; } for (final pt in (upserts['playlist_track'] as List? ?? const [])) { @@ -224,6 +242,15 @@ class SyncController extends AsyncNotifier { ); }); + // Fire-and-forget cover pre-warm so a cold-start scroll through + // the home grid paints from disk on the very first frame instead + // of firing one HTTP request per visible tile. Unawaited because + // the sync's "done" signal should fire as soon as the metadata + // delta is durable; cover downloads can finish in the background. + if (albumCoverIds.isNotEmpty || playlistCoverIds.isNotEmpty) { + unawaited(_prewarmCovers(albumCoverIds, playlistCoverIds)); + } + final result = SyncResult( upserts: upsertCount, deletes: deleteCount, @@ -237,6 +264,49 @@ class SyncController extends AsyncNotifier { } } + /// Downloads cover bytes for each id into the shared flutter_cache_ + /// manager disk cache that cached_network_image reads from. + /// Best-effort: per-URL failures (404 collage-not-built-yet, 401 + /// during a token refresh race, network blip) are swallowed so one + /// missing cover doesn't abort the rest. + /// + /// Concurrency 3 balances disk-warming throughput against starving + /// foreground UI work — covers are ~30-200KB each, so three in + /// flight saturates most LAN connections without pinning the radio. + Future _prewarmCovers( + List albumIds, + List playlistIds, + ) async { + final baseUrl = await ref.read(serverUrlProvider.future); + if (baseUrl == null || baseUrl.isEmpty) return; + final token = await ref.read(sessionTokenProvider.future); + final headers = (token != null && token.isNotEmpty) + ? {'Authorization': 'Bearer $token'} + : {}; + final base = + baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; + final urls = [ + for (final id in albumIds) '$base/api/albums/$id/cover', + for (final id in playlistIds) '$base/api/playlists/$id/cover', + ]; + final mgr = DefaultCacheManager(); + var index = 0; + Future worker() async { + while (index < urls.length) { + final i = index++; + try { + await mgr.downloadFile(urls[i], authHeaders: headers); + } catch (_) { + // Best-effort prewarm — failures are expected for system + // playlists whose collage hasn't been built yet, and for + // any transient auth race. Don't surface or abort. + } + } + } + + await Future.wait(List.generate(3, (_) => worker())); + } + CachedArtistsCompanion _artistFromJson(Map j) => CachedArtistsCompanion.insert( id: j['id'] as String, diff --git a/flutter_client/lib/cache/tile_providers.dart b/flutter_client/lib/cache/tile_providers.dart new file mode 100644 index 00000000..8d9be83f --- /dev/null +++ b/flutter_client/lib/cache/tile_providers.dart @@ -0,0 +1,127 @@ +// Per-entity tile providers for the per-item rendering architecture +// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md). +// +// Each provider is a StreamProvider.family keyed +// by entity id. The stream: +// 1. Watches the entity's drift row +// 2. Yields the populated row on every emission +// 3. Yields null while the row is absent (UI shows skeleton) +// 4. On the first missing-row emission, enqueues a hydration via +// HydrationQueue so the row eventually lands +// +// The tile widget reacts to AsyncValue: +// - loading or data:null → skeleton +// - data:non-null → real card +// - error → error placeholder +// +// Subscriptions are per-tile. A 50-tile home screen creates 50 drift +// subscriptions; drift handles this fine in practice but worth +// measuring if a future surface scales to hundreds. + +import 'package:drift/drift.dart' show leftOuterJoin; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../cache/adapters.dart'; +import '../cache/audio_cache_manager.dart' show appDbProvider; +import '../models/album.dart'; +import '../models/artist.dart'; +import '../models/track.dart'; +import 'hydration_queue.dart'; + +/// Watches the cached_albums row for [id]. Triggers background +/// hydration on miss; yields the row once it lands. +final albumTileProvider = + StreamProvider.family((ref, id) async* { + if (id.isEmpty) { + yield null; + return; + } + final db = ref.watch(appDbProvider); + final query = (db.select(db.cachedAlbums)..where((t) => t.id.equals(id))) + .join([ + leftOuterJoin(db.cachedArtists, + db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), + ]); + var enqueued = false; + await for (final rows in query.watch()) { + if (rows.isEmpty) { + if (!enqueued) { + enqueued = true; + ref + .read(hydrationQueueProvider) + .enqueue(entityType: 'album', entityId: id); + } + yield null; + continue; + } + final r = rows.first; + final album = r.readTable(db.cachedAlbums); + final artist = r.readTableOrNull(db.cachedArtists); + yield album.toRef(artistName: artist?.name ?? ''); + } +}); + +/// Watches the cached_artists row for [id]. Triggers background +/// hydration on miss; yields the row once it lands. +final artistTileProvider = + StreamProvider.family((ref, id) async* { + if (id.isEmpty) { + yield null; + return; + } + final db = ref.watch(appDbProvider); + final query = db.select(db.cachedArtists)..where((t) => t.id.equals(id)); + var enqueued = false; + await for (final rows in query.watch()) { + if (rows.isEmpty) { + if (!enqueued) { + enqueued = true; + ref + .read(hydrationQueueProvider) + .enqueue(entityType: 'artist', entityId: id); + } + yield null; + continue; + } + yield rows.first.toRef(); + } +}); + +/// Watches the cached_tracks row for [id]. Triggers background +/// hydration on miss; yields the row once it lands. +final trackTileProvider = + StreamProvider.family((ref, id) async* { + if (id.isEmpty) { + yield null; + return; + } + final db = ref.watch(appDbProvider); + final query = (db.select(db.cachedTracks)..where((t) => t.id.equals(id))) + .join([ + leftOuterJoin(db.cachedArtists, + db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), + leftOuterJoin(db.cachedAlbums, + db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)), + ]); + var enqueued = false; + await for (final rows in query.watch()) { + if (rows.isEmpty) { + if (!enqueued) { + enqueued = true; + ref + .read(hydrationQueueProvider) + .enqueue(entityType: 'track', entityId: id); + } + yield null; + continue; + } + final r = rows.first; + final t = r.readTable(db.cachedTracks); + final a = r.readTableOrNull(db.cachedArtists); + final al = r.readTableOrNull(db.cachedAlbums); + yield t.toRef( + artistName: a?.name ?? '', + albumTitle: al?.title ?? '', + ); + } +}); diff --git a/flutter_client/lib/discover/discover_screen.dart b/flutter_client/lib/discover/discover_screen.dart index 409347be..ff1ea8a2 100644 --- a/flutter_client/lib/discover/discover_screen.dart +++ b/flutter_client/lib/discover/discover_screen.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -204,9 +205,14 @@ class _ResultTile extends StatelessWidget { color: fs.slate, child: row.imageUrl.isEmpty ? Icon(Icons.album, color: fs.ash) - : Image.network(row.imageUrl, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Icon(Icons.album, color: fs.ash)), + : CachedNetworkImage( + imageUrl: row.imageUrl, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 120), + fadeOutDuration: Duration.zero, + errorWidget: (_, __, ___) => + Icon(Icons.album, color: fs.ash), + ), ), ), const SizedBox(width: 12), diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index 41dc5d04..968ed772 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -4,8 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../api/errors.dart'; -import '../models/album.dart'; -import '../models/artist.dart'; +import '../cache/tile_providers.dart'; import '../models/playlist.dart'; import '../models/system_playlists_status.dart'; import '../models/track.dart'; @@ -14,6 +13,7 @@ import '../playlists/widgets/playlist_card.dart'; import '../playlists/widgets/playlist_placeholder_card.dart'; import '../shared/widgets/connection_error_banner.dart'; import '../shared/widgets/main_app_bar_actions.dart'; +import '../shared/widgets/skeletons.dart'; import '../theme/theme_extension.dart'; import 'library_providers.dart'; import 'widgets/album_card.dart'; @@ -21,13 +21,18 @@ import 'widgets/artist_card.dart'; import 'widgets/compact_track_card.dart'; import 'widgets/horizontal_scroll_row.dart'; +/// Home screen. Per-item rendering: a tiny /api/home/index discovery +/// fetch returns just section→IDs, then each tile hydrates itself +/// against the per-entity drift tables (sync-populated) with REST +/// fallback via the HydrationQueue. Cold-visit dead air shrinks to a +/// single small round-trip; tiles materialize as their data lands. class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; - final home = ref.watch(homeProvider); + final index = ref.watch(homeIndexProvider); final allPlaylists = ref.watch(playlistsListProvider('all')); final status = ref.watch(systemPlaylistsStatusProvider); return Scaffold( @@ -39,40 +44,33 @@ class HomeScreen extends ConsumerWidget { actions: const [MainAppBarActions(currentRoute: '/home')], ), body: SafeArea( - child: home.when( + child: index.when( error: (e, _) { final code = e is DioException ? ApiError.fromDio(e).code : 'unknown'; if (code == 'connection_refused') { return ConnectionErrorBanner( - onRetry: () => ref.refresh(homeProvider), + onRetry: () => ref.refresh(homeIndexProvider), ); } return Center(child: Text('$e', style: TextStyle(color: fs.error))); }, loading: () => _HomeSkeleton(fs: fs), data: (h) => RefreshIndicator( - onRefresh: () async => ref.refresh(homeProvider.future), + onRefresh: () async => ref.refresh(homeIndexProvider.future), child: ListView( - // ClampingScrollPhysics: no bounce overscroll past the - // content. Combined with the bottom padding below, this - // makes "scroll to end" land the last item just above the - // player bar — no empty void. physics: const ClampingScrollPhysics(), children: [ _PlaylistsSection( playlists: allPlaylists.value?.owned ?? const [], status: status.value ?? SystemPlaylistsStatus.empty(), ), - _RecentlyAddedSection(albums: h.recentlyAddedAlbums), + _RecentlyAddedSection(ids: h.recentlyAddedAlbums), _RediscoverSection( - albums: h.rediscoverAlbums, - artists: h.rediscoverArtists, + albumIds: h.rediscoverAlbums, + artistIds: h.rediscoverArtists, ), - _MostPlayedSection(tracks: h.mostPlayedTracks), - _LastPlayedSection(artists: h.lastPlayedArtists), - // Bottom padding ≈ player bar height (top row 80 + seek - // 30 + padding 16 ≈ ~140) so the last section's bottom - // edge lands right above the player when scrolled. + _MostPlayedSection(ids: h.mostPlayedTracks), + _LastPlayedSection(ids: h.lastPlayedArtists), const SizedBox(height: 140), ], ), @@ -83,6 +81,146 @@ class HomeScreen extends ConsumerWidget { } } +// ─── Per-tile widgets ──────────────────────────────────────────────── + +/// Duration of the skeleton→content cross-fade. 220ms reads as "tile +/// settled into place" — longer drags, shorter feels like a hard cut. +/// Each tile cross-fades independently when its data lands, so the +/// natural cascade from the hydration queue's bounded concurrency +/// produces a staged-reveal feel without any per-tile delay math. +const Duration _tileRevealDuration = Duration(milliseconds: 220); + +/// Album tile: skeleton until albumTileProvider yields a populated row. +class _AlbumTile extends ConsumerWidget { + const _AlbumTile({required this.id}); + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncAlbum = ref.watch(albumTileProvider(id)); + final album = asyncAlbum.asData?.value; + return AnimatedSwitcher( + duration: _tileRevealDuration, + switchInCurve: Curves.easeOut, + child: album == null + ? const SkeletonAlbumTile(key: ValueKey('skeleton')) + : AlbumCard( + key: ValueKey('album-${album.id}'), + album: album, + onTap: () => + context.push('/albums/${album.id}', extra: album), + ), + ); + } +} + +/// Artist tile: skeleton until artistTileProvider yields a populated row. +class _ArtistTile extends ConsumerWidget { + const _ArtistTile({required this.id}); + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncArtist = ref.watch(artistTileProvider(id)); + final artist = asyncArtist.asData?.value; + return AnimatedSwitcher( + duration: _tileRevealDuration, + switchInCurve: Curves.easeOut, + child: artist == null + ? const SkeletonArtistTile(key: ValueKey('skeleton')) + : ArtistCard( + key: ValueKey('artist-${artist.id}'), + artist: artist, + onTap: () => + context.push('/artists/${artist.id}', extra: artist), + ), + ); + } +} + +/// Track tile (used by Most-Played). On tap, gathers the currently- +/// hydrated TrackRefs for the whole section so playback flows like +/// it did with the bulk-loaded list. Tracks that haven't hydrated yet +/// are skipped from the play list — typically transient on a cold +/// visit since hydration runs in parallel and finishes quickly. +class _TrackTile extends ConsumerWidget { + const _TrackTile({required this.id, required this.sectionIds}); + final String id; + final List sectionIds; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncTrack = ref.watch(trackTileProvider(id)); + final track = asyncTrack.asData?.value; + return AnimatedSwitcher( + duration: _tileRevealDuration, + switchInCurve: Curves.easeOut, + child: track == null + ? const _CompactTrackSkeleton(key: ValueKey('skeleton')) + : CompactTrackCard( + key: ValueKey('track-${track.id}'), + track: track, + sectionTracks: _resolveSectionTracks(ref, sectionIds), + index: + sectionIds.indexOf(id).clamp(0, sectionIds.length - 1), + ), + ); + } + + /// Snapshots the section's current track states. Used at tile + /// construction time — CompactTrackCard's onTap uses it as the play + /// queue. Tracks still hydrating are dropped; once they land, a + /// rebuild re-runs this lookup so the queue grows naturally. + static List _resolveSectionTracks(WidgetRef ref, List ids) { + final out = []; + for (final i in ids) { + final v = ref.read(trackTileProvider(i)).asData?.value; + if (v != null) out.add(v); + } + return out; + } +} + +/// Compact-track placeholder. 56dp cover + two text lines, matched to +/// CompactTrackCard so swapping in the real card doesn't shift the +/// row's height. +class _CompactTrackSkeleton extends StatelessWidget { + const _CompactTrackSkeleton({super.key}); + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Container( + width: 240, + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row(children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: fs.slate, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container(width: 120, height: 12, color: fs.slate), + const SizedBox(height: 6), + Container(width: 80, height: 10, color: fs.slate), + ], + ), + ), + ]), + ); + } +} + +// ─── Sections ──────────────────────────────────────────────────────── + class _PlaylistsSection extends StatelessWidget { const _PlaylistsSection({required this.playlists, required this.status}); final List playlists; @@ -129,22 +267,16 @@ List<_PlaylistRowItem> _buildPlaylistsRow( return null; } - // Slot 1: For-You. final forYou = findFirst((p) => p.systemVariant == 'for_you'); out.add(forYou != null ? _RealPlaylist(forYou) : _PlaceholderPlaylist('For You', _variantFor('for-you', status))); - // Slot 2: Discover. Server emits this with system_variant='discover' - // when the recommendation engine has eligible tracks; before then, - // show a placeholder in the same "building/pending" state machine - // as For-You. final discover = findFirst((p) => p.systemVariant == 'discover'); out.add(discover != null ? _RealPlaylist(discover) : _PlaceholderPlaylist('Discover', _variantFor('discover', status))); - // Slots 3-5: Songs-like (real first, padded to 3). final songsLike = ownedAll .where((p) => p.systemVariant == 'songs_like_artist') .take(3) @@ -155,7 +287,6 @@ List<_PlaylistRowItem> _buildPlaylistsRow( : _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status))); } - // User-created trail (server returns most-recently-updated first). for (final p in ownedAll.where((p) => p.systemVariant == null)) { out.add(_RealPlaylist(p)); } @@ -171,44 +302,38 @@ String _variantFor(String slot, SystemPlaylistsStatus s) { } class _RecentlyAddedSection extends StatelessWidget { - const _RecentlyAddedSection({required this.albums}); - final List albums; + const _RecentlyAddedSection({required this.ids}); + final List ids; @override Widget build(BuildContext context) { - if (albums.isEmpty) { + if (ids.isEmpty) { return const _EmptySection( title: 'Recently added', message: "Nothing added yet. Scan a folder via the server's config.", ); } final controller = ScrollController(); - final rows = _chunk(albums, 25); + final rows = _chunk(ids, 25); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var i = 0; i < rows.length; i++) HorizontalScrollRow( title: i == 0 ? 'Recently added' : '', controller: controller, - children: rows[i] - .map((a) => AlbumCard( - album: a, - onTap: () => - context.push('/albums/${a.id}', extra: a), - )) - .toList(), + children: rows[i].map((id) => _AlbumTile(id: id)).toList(), ), ]); } } class _RediscoverSection extends StatelessWidget { - const _RediscoverSection({required this.albums, required this.artists}); - final List albums; - final List artists; + const _RediscoverSection({required this.albumIds, required this.artistIds}); + final List albumIds; + final List artistIds; @override Widget build(BuildContext context) { - if (albums.isEmpty && artists.isEmpty) { + if (albumIds.isEmpty && artistIds.isEmpty) { return const _EmptySection( title: 'Rediscover', message: @@ -216,47 +341,35 @@ class _RediscoverSection extends StatelessWidget { ); } return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (albums.isNotEmpty) + if (albumIds.isNotEmpty) HorizontalScrollRow( title: 'Rediscover', - children: albums - .map((a) => AlbumCard( - album: a, - onTap: () => - context.push('/albums/${a.id}', extra: a), - )) - .toList(), + children: albumIds.map((id) => _AlbumTile(id: id)).toList(), ), - if (artists.isNotEmpty) + if (artistIds.isNotEmpty) HorizontalScrollRow( - title: albums.isEmpty ? 'Rediscover' : '', + title: albumIds.isEmpty ? 'Rediscover' : '', height: 168, - children: artists - .map((ar) => ArtistCard( - artist: ar, - onTap: () => - context.push('/artists/${ar.id}', extra: ar), - )) - .toList(), + children: artistIds.map((id) => _ArtistTile(id: id)).toList(), ), ]); } } class _MostPlayedSection extends StatelessWidget { - const _MostPlayedSection({required this.tracks}); - final List tracks; + const _MostPlayedSection({required this.ids}); + final List ids; @override Widget build(BuildContext context) { - if (tracks.isEmpty) { + if (ids.isEmpty) { return const _EmptySection( title: 'Most played', message: 'No plays to draw from. Listen to something.', ); } final controller = ScrollController(); - final rows = _chunk(tracks, 25); + final rows = _chunk(ids, 25); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var i = 0; i < rows.length; i++) HorizontalScrollRow( @@ -264,12 +377,7 @@ class _MostPlayedSection extends StatelessWidget { height: 64, controller: controller, children: [ - for (var j = 0; j < rows[i].length; j++) - CompactTrackCard( - track: rows[i][j], - sectionTracks: tracks, - index: i * 25 + j, - ), + for (final id in rows[i]) _TrackTile(id: id, sectionIds: ids), ], ), ]); @@ -277,12 +385,12 @@ class _MostPlayedSection extends StatelessWidget { } class _LastPlayedSection extends StatelessWidget { - const _LastPlayedSection({required this.artists}); - final List artists; + const _LastPlayedSection({required this.ids}); + final List ids; @override Widget build(BuildContext context) { - if (artists.isEmpty) { + if (ids.isEmpty) { return const _EmptySection( title: 'Last played', message: 'No recent plays.', @@ -291,13 +399,7 @@ class _LastPlayedSection extends StatelessWidget { return HorizontalScrollRow( title: 'Last played', height: 168, - children: artists - .map((ar) => ArtistCard( - artist: ar, - onTap: () => - context.push('/artists/${ar.id}', extra: ar), - )) - .toList(), + children: ids.map((id) => _ArtistTile(id: id)).toList(), ); } } @@ -328,11 +430,11 @@ class _EmptySection extends StatelessWidget { } } -/// Cold-start skeleton. Renders the same shape as the real home (a few -/// section titles + card-sized placeholders) so the page feels alive -/// while /api/home is in flight, instead of a 30-second blank spinner. -/// Each section drops in independently as data arrives via the -/// individual providers. +/// Cold-start skeleton — shown only between mount and the first +/// homeIndexProvider emission (typically a single drift query + small +/// REST round-trip). Each section then renders its own per-tile +/// skeletons internally, so this widget's role is just the very first +/// pre-discovery frame. class _HomeSkeleton extends StatelessWidget { const _HomeSkeleton({required this.fs}); final FabledSwordTheme fs; @@ -385,26 +487,7 @@ class _SkeletonSection extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16), physics: const NeverScrollableScrollPhysics(), itemCount: 6, - itemBuilder: (_, __) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - color: fs.slate, - borderRadius: BorderRadius.circular(6), - ), - ), - const SizedBox(height: 8), - Container(width: coverSize * 0.7, height: 12, color: fs.slate), - const SizedBox(height: 4), - Container(width: coverSize * 0.4, height: 10, color: fs.iron), - ], - ), - ), + itemBuilder: (_, __) => SkeletonAlbumTile(width: cardWidth), ), ), ]); diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart index 9482c05c..f7065224 100644 --- a/flutter_client/lib/library/library_providers.dart +++ b/flutter_client/lib/library/library_providers.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart' as drift; @@ -16,6 +17,7 @@ import '../cache/db.dart'; import '../models/album.dart'; import '../models/artist.dart'; import '../models/home_data.dart'; +import '../models/home_index.dart'; import '../models/track.dart'; /// Shared authenticated dio. This is the ONLY place tokenResolver is wired @@ -42,10 +44,189 @@ final libraryApiProvider = FutureProvider((ref) async { return LibraryApi(await ref.watch(dioProvider.future)); }); -final homeProvider = FutureProvider((ref) async { - return (await ref.watch(libraryApiProvider.future)).getHome(); +/// Drift-first home data (#357 follow-up). The home screen is the first +/// view the user sees on app open; without a local cache the cold-start +/// blocks on the /api/home round-trip, which is the dominant felt- +/// latency on slow / remote connections. +/// +/// Cache layout: a single row in `cached_home_snapshot` storing the +/// last successful HomeData as JSON. On stream subscription: +/// +/// - If a row exists, yield it immediately (parsed from JSON) and +/// kick off a background revalidation against /api/home (SWR). +/// The drift watch() re-emits when the new row is written. +/// - If no row exists yet (cold cache), fetch /api/home synchronously, +/// upsert into drift, and emit. Subsequent emissions come from the +/// drift watch as background revalidations land. +/// +/// The HomeData JSON encodes back to the same wire shape /api/home +/// emits, so the existing HomeData.fromJson handles both sources. +final homeProvider = StreamProvider((ref) { + final db = ref.watch(appDbProvider); + return cacheFirst( + driftStream: db.select(db.cachedHomeSnapshot).watch(), + fetchAndPopulate: () async { + final api = await ref.read(libraryApiProvider.future); + final fresh = await api.getHome(); + await db.into(db.cachedHomeSnapshot).insertOnConflictUpdate( + CachedHomeSnapshotCompanion.insert( + json: _encodeHomeData(fresh), + updatedAt: drift.Value(DateTime.now()), + ), + ); + }, + toResult: (rows) => rows.isEmpty + ? const HomeData( + recentlyAddedAlbums: [], + rediscoverAlbums: [], + rediscoverArtists: [], + mostPlayedTracks: [], + lastPlayedArtists: [], + ) + : HomeData.fromJson(jsonDecode(rows.first.json) as Map), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + // SWR: when cached snapshot exists, still hit /api/home in the + // background so the user sees the freshest curation. The cache + // mostly serves the first frame, not the canonical state. + alwaysRefresh: true, + tag: 'home', + ); }); +/// Drift-first per-item home index. Reads from cached_home_index +/// (populated by /api/home/index discovery) and yields a HomeIndex +/// the screen consumes to lay out section→tile slots. Each tile is +/// rendered by a per-entity tile provider that hydrates itself. +/// +/// Section keys mirror the server's response shape so the encode / +/// decode round-trip is straightforward — the table stores +/// (section, position, entityType, entityId), and toResult reassembles +/// the parallel ID lists. +final homeIndexProvider = StreamProvider((ref) { + final db = ref.watch(appDbProvider); + final query = (db.select(db.cachedHomeIndex) + ..orderBy([ + (t) => drift.OrderingTerm.asc(t.section), + (t) => drift.OrderingTerm.asc(t.position), + ])) + .watch(); + return cacheFirst( + driftStream: query, + fetchAndPopulate: () async { + final api = await ref.read(libraryApiProvider.future); + final fresh = await api.getHomeIndex(); + // Full replace in a transaction so the watch() sees exactly one + // post-fetch emission with the merged state. Section ordering is + // re-asserted on read (orderBy above) so the write order doesn't + // matter — letting us batch flat instead of section-by-section. + await db.transaction(() async { + await db.delete(db.cachedHomeIndex).go(); + await db.batch((b) { + void rows(String section, String entityType, List ids) { + for (var i = 0; i < ids.length; i++) { + b.insert( + db.cachedHomeIndex, + CachedHomeIndexCompanion.insert( + section: section, + position: i, + entityType: entityType, + entityId: ids[i], + ), + ); + } + } + + rows('recently_added_albums', 'album', fresh.recentlyAddedAlbums); + rows('rediscover_albums', 'album', fresh.rediscoverAlbums); + rows('rediscover_artists', 'artist', fresh.rediscoverArtists); + rows('most_played_tracks', 'track', fresh.mostPlayedTracks); + rows('last_played_artists', 'artist', fresh.lastPlayedArtists); + }); + }); + }, + toResult: (rows) { + final recentlyAddedAlbums = []; + final rediscoverAlbums = []; + final rediscoverArtists = []; + final mostPlayedTracks = []; + final lastPlayedArtists = []; + for (final r in rows) { + switch (r.section) { + case 'recently_added_albums': + recentlyAddedAlbums.add(r.entityId); + case 'rediscover_albums': + rediscoverAlbums.add(r.entityId); + case 'rediscover_artists': + rediscoverArtists.add(r.entityId); + case 'most_played_tracks': + mostPlayedTracks.add(r.entityId); + case 'last_played_artists': + lastPlayedArtists.add(r.entityId); + } + } + return HomeIndex( + recentlyAddedAlbums: recentlyAddedAlbums, + rediscoverAlbums: rediscoverAlbums, + rediscoverArtists: rediscoverArtists, + mostPlayedTracks: mostPlayedTracks, + lastPlayedArtists: lastPlayedArtists, + ); + }, + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + // SWR: cached layout shows instantly; fresh /api/home/index lands + // in the background and tiles re-resolve as the table mutates. + alwaysRefresh: true, + tag: 'homeIndex', + ); +}); + +/// Encodes HomeData back to the wire-format JSON shape /api/home +/// emits, so HomeData.fromJson can round-trip through the drift cache. +String _encodeHomeData(HomeData h) => jsonEncode({ + 'recently_added_albums': h.recentlyAddedAlbums.map(_albumToJson).toList(), + 'rediscover_albums': h.rediscoverAlbums.map(_albumToJson).toList(), + 'rediscover_artists': h.rediscoverArtists.map(_artistToJson).toList(), + 'most_played_tracks': h.mostPlayedTracks.map(_trackToJson).toList(), + 'last_played_artists': h.lastPlayedArtists.map(_artistToJson).toList(), + }); + +Map _albumToJson(AlbumRef a) => { + 'id': a.id, + 'title': a.title, + 'sort_title': a.sortTitle, + 'artist_id': a.artistId, + 'artist_name': a.artistName, + 'year': a.year, + 'track_count': a.trackCount, + 'duration_sec': a.durationSec, + 'cover_url': a.coverUrl, + }; + +Map _artistToJson(ArtistRef a) => { + 'id': a.id, + 'name': a.name, + 'sort_name': a.sortName, + 'album_count': a.albumCount, + 'cover_url': a.coverUrl, + }; + +Map _trackToJson(TrackRef t) => { + 'id': t.id, + 'title': t.title, + 'album_id': t.albumId, + 'album_title': t.albumTitle, + 'artist_id': t.artistId, + 'artist_name': t.artistName, + 'track_number': t.trackNumber, + 'disc_number': t.discNumber, + 'duration_sec': t.durationSec, + 'stream_url': t.streamUrl, + }; + /// Drift-first per #357 plan C. Watches cached_artists for the row; /// when empty + online, fetches via REST + populates drift, which /// re-emits via watch(). diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index b3e85380..33ceb20a 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -5,7 +8,13 @@ import 'package:go_router/go_router.dart'; import '../api/endpoints/library_lists.dart'; import '../api/endpoints/likes.dart'; import '../api/endpoints/me.dart'; +import '../auth/auth_provider.dart' show authControllerProvider; +import '../cache/audio_cache_manager.dart' show appDbProvider; +import '../cache/cache_first.dart'; +import '../cache/connectivity_provider.dart'; +import '../cache/db.dart'; import '../cache/metadata_prefetcher.dart'; +import '../cache/tile_providers.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/album.dart'; import '../models/artist.dart'; @@ -16,7 +25,11 @@ import '../models/page.dart' as wire; import '../models/quarantine_mine.dart'; import '../models/track.dart'; import '../player/player_provider.dart'; +import '../quarantine/quarantine_provider.dart'; +import '../shared/live_events_provider.dart'; import '../shared/widgets/main_app_bar_actions.dart'; +import '../shared/widgets/skeletons.dart'; +import '../shared/widgets/server_image.dart'; import '../theme/theme_extension.dart'; import 'widgets/album_card.dart'; import 'widgets/artist_card.dart'; @@ -122,25 +135,187 @@ final _libraryAlbumsProvider = AsyncNotifierProvider< _LibraryAlbumsNotifier, wire.Paged>(_LibraryAlbumsNotifier.new); -final _historyProvider = FutureProvider((ref) async { - return (await ref.watch(_meApiProvider.future)).history(); +// Drift-first History tab. Mirrors homeProvider's pattern: store the +// last /api/me/history page as JSON in a single-row drift table, yield +// it immediately on subscribe (so the tab paints from disk on cold +// open), then SWR-refresh in the background. Also gives basic offline +// scrollback — the last fetched page survives connectivity loss. +// +// JSON blob (vs columnar) because the page is small, always read whole, +// and the HistoryPage.fromJson constructor already accepts the wire +// shape — no schema-evolution pain when server-side fields change. +final _historyProvider = StreamProvider((ref) { + final db = ref.watch(appDbProvider); + return cacheFirst( + driftStream: db.select(db.cachedHistorySnapshot).watch(), + fetchAndPopulate: () async { + final api = await ref.read(_meApiProvider.future); + final fresh = await api.history(); + await db.into(db.cachedHistorySnapshot).insertOnConflictUpdate( + CachedHistorySnapshotCompanion.insert( + json: _encodeHistoryPage(fresh), + updatedAt: drift.Value(DateTime.now()), + ), + ); + }, + toResult: (rows) => rows.isEmpty + ? const HistoryPage(events: [], hasMore: false) + : HistoryPage.fromJson( + jsonDecode(rows.first.json) as Map), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + // SWR: yield cache instantly, then refresh in the background so the + // tab reflects the freshest plays. Matches homeProvider behavior. + alwaysRefresh: true, + tag: 'history', + ); }); -final _likedTracksProvider = FutureProvider>((ref) async { - return (await ref.watch(_likesApiProvider.future)).listTracks(); +/// Encodes HistoryPage back to the wire-format JSON shape +/// /api/me/history emits, so HistoryPage.fromJson can round-trip +/// through the drift cache. +String _encodeHistoryPage(HistoryPage h) => jsonEncode({ + 'events': h.events + .map((e) => { + 'id': e.id, + 'played_at': e.playedAt, + 'track': { + 'id': e.track.id, + 'title': e.track.title, + 'album_id': e.track.albumId, + 'album_title': e.track.albumTitle, + 'artist_id': e.track.artistId, + 'artist_name': e.track.artistName, + 'track_number': e.track.trackNumber, + 'disc_number': e.track.discNumber, + 'duration_sec': e.track.durationSec, + 'stream_url': e.track.streamUrl, + }, + }) + .toList(), + 'has_more': h.hasMore, + }); + +// Per-item Liked tabs (Slice E of the per-item rendering pass). +// Each provider yields just the ordered list of entity IDs; the UI +// then renders per-tile widgets that hydrate each entity individually +// via albumTileProvider / artistTileProvider / trackTileProvider. +// +// Reads come from cached_likes (sync- and optimistic-write-populated), +// projected with ORDER BY likedAt DESC. fetchAndPopulate hits the +// cheap /api/likes/ids endpoint — the bulk /api/likes/* endpoints +// that returned fully denormalized entities are no longer needed for +// this path since tile providers handle entity hydration themselves. +// +// likedAt ordering note: cached_likes.likedAt is whatever drift +// assigned via currentDateAndTime when the row was first inserted +// (either by sync or LikesController). insertOrIgnore on subsequent +// fetches preserves the existing likedAt so ordering stays stable. +// Approximate but acceptable — matches prior Slice 3 behavior. + +List _idsForEntity(List rows) => + rows.map((r) => r.entityId).toList(growable: false); + +final _likedTrackIdsProvider = StreamProvider>((ref) { + final db = ref.watch(appDbProvider); + final query = (db.select(db.cachedLikes) + ..where((t) => t.entityType.equals('track')) + ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) + .watch(); + return cacheFirst>( + driftStream: query, + fetchAndPopulate: () => _populateLikeIds(ref), + toResult: _idsForEntity, + isOnline: () async => (await ref.read(connectivityProvider.future)), + alwaysRefresh: true, + tag: 'likedTrackIds', + ); }); -final _likedAlbumsProvider = FutureProvider>((ref) async { - return (await ref.watch(_likesApiProvider.future)).listAlbums(); +final _likedAlbumIdsProvider = StreamProvider>((ref) { + final db = ref.watch(appDbProvider); + final query = (db.select(db.cachedLikes) + ..where((t) => t.entityType.equals('album')) + ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) + .watch(); + return cacheFirst>( + driftStream: query, + fetchAndPopulate: () => _populateLikeIds(ref), + toResult: _idsForEntity, + isOnline: () async => (await ref.read(connectivityProvider.future)), + alwaysRefresh: true, + tag: 'likedAlbumIds', + ); }); -final _likedArtistsProvider = FutureProvider>((ref) async { - return (await ref.watch(_likesApiProvider.future)).listArtists(); +final _likedArtistIdsProvider = StreamProvider>((ref) { + final db = ref.watch(appDbProvider); + final query = (db.select(db.cachedLikes) + ..where((t) => t.entityType.equals('artist')) + ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) + .watch(); + return cacheFirst>( + driftStream: query, + fetchAndPopulate: () => _populateLikeIds(ref), + toResult: _idsForEntity, + isOnline: () async => (await ref.read(connectivityProvider.future)), + alwaysRefresh: true, + tag: 'likedArtistIds', + ); }); -final _quarantineProvider = FutureProvider>((ref) async { - return (await ref.watch(_meApiProvider.future)).quarantineMine(); -}); +/// Shared cold-cache populator: hits /api/likes/ids once and writes +/// rows for all three entity types via insertOrIgnore. The three +/// providers above all trigger this on their first empty-drift +/// emission; the dedup in cacheFirst's revalidate state plus drift's +/// insertOrIgnore semantics make the multiple-trigger case cheap. +Future _populateLikeIds(Ref ref) async { + final api = await ref.read(_likesApiProvider.future); + final user = ref.read(authControllerProvider).value; + if (user == null) return; + final fresh = await api.ids(); + final db = ref.read(appDbProvider); + await db.batch((b) { + for (final id in fresh.tracks) { + b.insert( + db.cachedLikes, + CachedLikesCompanion.insert( + userId: user.id, + entityType: 'track', + entityId: id, + ), + mode: drift.InsertMode.insertOrIgnore, + ); + } + for (final id in fresh.albums) { + b.insert( + db.cachedLikes, + CachedLikesCompanion.insert( + userId: user.id, + entityType: 'album', + entityId: id, + ), + mode: drift.InsertMode.insertOrIgnore, + ); + } + for (final id in fresh.artists) { + b.insert( + db.cachedLikes, + CachedLikesCompanion.insert( + userId: user.id, + entityType: 'artist', + entityId: id, + ), + mode: drift.InsertMode.insertOrIgnore, + ); + } + }); +} + +// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with +// optimistic flag/unflag) so flagging from any kebab and unflagging from +// the Hidden tab keep one source of truth. class LibraryScreen extends ConsumerStatefulWidget { const LibraryScreen({super.key}); @@ -373,79 +548,82 @@ class _LikedTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; - final tracksA = ref.watch(_likedTracksProvider); - final albumsA = ref.watch(_likedAlbumsProvider); - final artistsA = ref.watch(_likedArtistsProvider); + // SSE wire-up: any cross-device like / unlike triggers a refresh + // of the discovery providers. LikesController handles local + // mutations optimistically through the same cached_likes table, + // so toggling a like locally re-emits the streams instantly + // without needing an invalidate here. + ref.listen>(liveEventsProvider, (_, next) { + final e = next.asData?.value; + if (e == null) return; + switch (e.kind) { + case 'track.liked': + case 'track.unliked': + case 'album.liked': + case 'album.unliked': + case 'artist.liked': + case 'artist.unliked': + ref.invalidate(_likedTrackIdsProvider); + ref.invalidate(_likedAlbumIdsProvider); + ref.invalidate(_likedArtistIdsProvider); + } + }); + final tracksA = ref.watch(_likedTrackIdsProvider); + final albumsA = ref.watch(_likedAlbumIdsProvider); + final artistsA = ref.watch(_likedArtistIdsProvider); if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) { return const Center(child: CircularProgressIndicator()); } - final t = tracksA.value; - final al = albumsA.value; - final ar = artistsA.value; - if (t == null || al == null || ar == null) { + final trackIds = tracksA.value; + final albumIds = albumsA.value; + final artistIds = artistsA.value; + if (trackIds == null || albumIds == null || artistIds == null) { return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error))); } - if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) { + if (trackIds.isEmpty && albumIds.isEmpty && artistIds.isEmpty) { return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center)); } return RefreshIndicator( onRefresh: () async { await Future.wait([ - ref.refresh(_likedTracksProvider.future), - ref.refresh(_likedAlbumsProvider.future), - ref.refresh(_likedArtistsProvider.future), + ref.refresh(_likedTrackIdsProvider.future), + ref.refresh(_likedAlbumIdsProvider.future), + ref.refresh(_likedArtistIdsProvider.future), ]); }, child: ListView(children: [ - if (ar.items.isNotEmpty) ...[ - _SectionHeader(label: 'Artists', count: ar.total), + if (artistIds.isNotEmpty) ...[ + _SectionHeader(label: 'Artists', count: artistIds.length), SizedBox( height: 168, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: ar.items.length, - itemBuilder: (ctx, i) { - final artist = ar.items[i]; - return ArtistCard( - artist: artist, - onTap: () => - ctx.push('/artists/${artist.id}', extra: artist), - ); - }, + itemCount: artistIds.length, + itemBuilder: (ctx, i) => + _LikedArtistTile(id: artistIds[i]), ), ), ], - if (al.items.isNotEmpty) ...[ - _SectionHeader(label: 'Albums', count: al.total), + if (albumIds.isNotEmpty) ...[ + _SectionHeader(label: 'Albums', count: albumIds.length), SizedBox( height: 200, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: al.items.length, - itemBuilder: (ctx, i) { - final album = al.items[i]; - return AlbumCard( - album: album, - onTap: () => - ctx.push('/albums/${album.id}', extra: album), - ); - }, + itemCount: albumIds.length, + itemBuilder: (ctx, i) => + _LikedAlbumTile(id: albumIds[i]), ), ), ], - if (t.items.isNotEmpty) ...[ - _SectionHeader(label: 'Tracks', count: t.total), - ...t.items.asMap().entries.map((e) { - return TrackRow( - track: e.value, - onTap: () => ref - .read(playerActionsProvider) - .playTracks(t.items, initialIndex: e.key), - ); - }), + if (trackIds.isNotEmpty) ...[ + _SectionHeader(label: 'Tracks', count: trackIds.length), + ...trackIds.map( + (id) => _LikedTrackRow(id: id, sectionIds: trackIds), + ), ], const SizedBox(height: 96), ]), @@ -453,13 +631,102 @@ class _LikedTab extends ConsumerWidget { } } +/// Skeleton→content cross-fade duration. Matches home_screen so the +/// reveal feel is consistent across surfaces. See _tileRevealDuration +/// in home_screen.dart for the rationale. +const Duration _likedTileReveal = Duration(milliseconds: 220); + +/// Liked-Artists carousel tile. Skeleton until artistTileProvider +/// yields a populated row. +class _LikedArtistTile extends ConsumerWidget { + const _LikedArtistTile({required this.id}); + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final artist = ref.watch(artistTileProvider(id)).asData?.value; + return AnimatedSwitcher( + duration: _likedTileReveal, + switchInCurve: Curves.easeOut, + child: artist == null + ? const SkeletonArtistTile(key: ValueKey('skeleton')) + : ArtistCard( + key: ValueKey('artist-${artist.id}'), + artist: artist, + onTap: () => + context.push('/artists/${artist.id}', extra: artist), + ), + ); + } +} + +/// Liked-Albums carousel tile. Skeleton until albumTileProvider +/// yields a populated row. +class _LikedAlbumTile extends ConsumerWidget { + const _LikedAlbumTile({required this.id}); + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final album = ref.watch(albumTileProvider(id)).asData?.value; + return AnimatedSwitcher( + duration: _likedTileReveal, + switchInCurve: Curves.easeOut, + child: album == null + ? const SkeletonAlbumTile(key: ValueKey('skeleton')) + : AlbumCard( + key: ValueKey('album-${album.id}'), + album: album, + onTap: () => context.push('/albums/${album.id}', extra: album), + ), + ); + } +} + +/// Liked-Tracks list row. Skeleton until trackTileProvider yields a +/// populated row. Tap plays the section starting at this track, +/// using whichever tracks are currently hydrated; still-loading +/// tracks are skipped from the play queue and join on next rebuild. +class _LikedTrackRow extends ConsumerWidget { + const _LikedTrackRow({required this.id, required this.sectionIds}); + final String id; + final List sectionIds; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final track = ref.watch(trackTileProvider(id)).asData?.value; + return AnimatedSwitcher( + duration: _likedTileReveal, + switchInCurve: Curves.easeOut, + child: track == null + ? const SkeletonTrackRow(key: ValueKey('skeleton')) + : TrackRow( + key: ValueKey('track-${track.id}'), + track: track, + onTap: () { + final hydrated = []; + for (final i in sectionIds) { + final v = ref.read(trackTileProvider(i)).asData?.value; + if (v != null) hydrated.add(v); + } + final start = hydrated.indexWhere((t) => t.id == id); + ref.read(playerActionsProvider).playTracks( + hydrated, + initialIndex: start < 0 ? 0 : start, + ); + }, + ), + ); + } +} + class _HiddenTab extends ConsumerWidget { const _HiddenTab(); @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; - return ref.watch(_quarantineProvider).when( + return ref.watch(myQuarantineProvider).when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), data: (rows) => rows.isEmpty @@ -471,7 +738,7 @@ class _HiddenTab extends ConsumerWidget { ), ) : RefreshIndicator( - onRefresh: () async => ref.refresh(_quarantineProvider.future), + onRefresh: () async => ref.refresh(myQuarantineProvider.future), child: ListView.separated( itemCount: rows.length, separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), @@ -482,12 +749,12 @@ class _HiddenTab extends ConsumerWidget { } } -class _QuarantineTile extends StatelessWidget { +class _QuarantineTile extends ConsumerWidget { const _QuarantineTile({required this.row}); final QuarantineMineRow row; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final reasonLabel = switch (row.reason) { 'bad_rip' => 'Bad rip', @@ -496,9 +763,25 @@ class _QuarantineTile extends StatelessWidget { 'duplicate' => 'Duplicate', _ => 'Other', }; + final coverUrl = row.albumId.isNotEmpty ? '/api/albums/${row.albumId}/cover' : ''; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + width: 56, + height: 56, + child: coverUrl.isEmpty + ? Container(color: fs.slate) + : ServerImage( + url: coverUrl, + fit: BoxFit.cover, + fallback: Container(color: fs.slate), + ), + ), + ), + const SizedBox(width: 12), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -515,17 +798,41 @@ class _QuarantineTile extends StatelessWidget { ), Padding( padding: const EdgeInsets.only(top: 4), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(4), + child: Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: fs.iron, + borderRadius: BorderRadius.circular(4), + ), + child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)), ), - child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)), - ), + if (row.createdAt.isNotEmpty) ...[ + const SizedBox(width: 8), + Text(_relativeTime(row.createdAt), + style: TextStyle(color: fs.ash, fontSize: 11)), + ], + ]), ), ]), ), + IconButton( + tooltip: 'Unhide', + icon: Icon(Icons.restore, color: fs.ash, size: 20), + onPressed: () async { + try { + await ref.read(myQuarantineProvider.notifier).unflag(row.trackId); + } catch (_) { + // Optimistic rollback handled by the notifier; surface + // the failure only if the user kept the tab open. + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not unhide; try again.')), + ); + } + } + }, + ), ]), ); } diff --git a/flutter_client/lib/library/widgets/album_card.dart b/flutter_client/lib/library/widgets/album_card.dart index 24698634..533b84a8 100644 --- a/flutter_client/lib/library/widgets/album_card.dart +++ b/flutter_client/lib/library/widgets/album_card.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../models/album.dart'; +import '../../player/player_provider.dart'; import '../../shared/widgets/server_image.dart'; import '../../theme/theme_extension.dart'; +import '../library_providers.dart'; +import 'play_circle_button.dart'; -class AlbumCard extends StatelessWidget { +class AlbumCard extends ConsumerWidget { const AlbumCard({ required this.album, required this.onTap, @@ -32,7 +36,7 @@ class AlbumCard extends StatelessWidget { final bool showArtist; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final coverSize = width - 16; return SizedBox( @@ -47,17 +51,29 @@ class AlbumCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: coverSize, - height: coverSize, - color: fs.slate, - child: album.coverUrl.isEmpty - ? SvgPicture.asset('assets/svg/album-fallback.svg', - fit: BoxFit.cover) - : ServerImage(url: album.coverUrl, fit: BoxFit.cover), - ), + // Stack: cover image + overlaid play button at bottom-right. + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Container( + width: coverSize, + height: coverSize, + color: fs.slate, + child: album.coverUrl.isEmpty + ? SvgPicture.asset('assets/svg/album-fallback.svg', + fit: BoxFit.cover) + : ServerImage(url: album.coverUrl, fit: BoxFit.cover), + ), + ), + Positioned( + bottom: 6, + right: 6, + child: PlayCircleButton( + onPressed: () => _playAlbum(ref), + ), + ), + ], ), const SizedBox(height: 8), Text( @@ -80,4 +96,17 @@ class AlbumCard extends StatelessWidget { ), ); } + + /// Fetches the album's tracks via /api/albums/{id} and starts playback + /// from the first track. Errors are swallowed by the button's outer + /// try/finally; callers don't surface them — failed fetches just keep + /// the spinner visible until the button is retapped. + Future _playAlbum(WidgetRef ref) async { + final api = await ref.read(libraryApiProvider.future); + final result = await api.getAlbum(album.id); + if (result.tracks.isEmpty) return; + await ref + .read(playerActionsProvider) + .playTracks(result.tracks, initialIndex: 0); + } } diff --git a/flutter_client/lib/library/widgets/artist_card.dart b/flutter_client/lib/library/widgets/artist_card.dart index 218a30ba..3d6bd6b2 100644 --- a/flutter_client/lib/library/widgets/artist_card.dart +++ b/flutter_client/lib/library/widgets/artist_card.dart @@ -1,17 +1,23 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../models/artist.dart'; +import '../../player/player_provider.dart'; import '../../shared/widgets/server_image.dart'; import '../../theme/theme_extension.dart'; +import '../library_providers.dart'; +import 'play_circle_button.dart'; -class ArtistCard extends StatelessWidget { +class ArtistCard extends ConsumerWidget { const ArtistCard({required this.artist, required this.onTap, super.key}); final ArtistRef artist; final VoidCallback onTap; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; return SizedBox( width: 140, @@ -22,16 +28,31 @@ class ArtistCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipOval( - child: Container( - width: 124, - height: 124, - color: fs.slate, - child: artist.coverUrl.isEmpty - ? SvgPicture.asset('assets/svg/album-fallback.svg', - fit: BoxFit.cover) - : ServerImage(url: artist.coverUrl, fit: BoxFit.cover), - ), + // Stack: circular avatar + overlaid play button at bottom-right. + // The avatar is a circle (ClipOval) — bottom-right of its + // bounding box still places the button inside the visible area + // because the button is small relative to the radius. + Stack( + children: [ + ClipOval( + child: Container( + width: 124, + height: 124, + color: fs.slate, + child: artist.coverUrl.isEmpty + ? SvgPicture.asset('assets/svg/album-fallback.svg', + fit: BoxFit.cover) + : ServerImage(url: artist.coverUrl, fit: BoxFit.cover), + ), + ), + Positioned( + bottom: 4, + right: 4, + child: PlayCircleButton( + onPressed: () => _playArtistShuffle(ref), + ), + ), + ], ), const SizedBox(height: 8), Text( @@ -46,4 +67,18 @@ class ArtistCard extends StatelessWidget { ), ); } + + /// Fetches the artist's tracks via /api/artists/{id}/tracks, shuffles + /// them (Fisher-Yates, default Random), and plays from index 0. + /// Matches the web ArtistCard's `playQueue(shuffle(tracks), 0)`. + Future _playArtistShuffle(WidgetRef ref) async { + final api = await ref.read(libraryApiProvider.future); + final tracks = await api.getArtistTracks(artist.id); + if (tracks.isEmpty) return; + final shuffled = List.of(tracks); + shuffled.shuffle(Random()); + await ref + .read(playerActionsProvider) + .playTracks(shuffled, initialIndex: 0); + } } diff --git a/flutter_client/lib/library/widgets/play_circle_button.dart b/flutter_client/lib/library/widgets/play_circle_button.dart new file mode 100644 index 00000000..6c76dac7 --- /dev/null +++ b/flutter_client/lib/library/widgets/play_circle_button.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +import '../../theme/theme_extension.dart'; + +/// Always-visible 44dp circular play button overlaid on home-screen +/// card art (AlbumCard / ArtistCard / PlaylistCard). Mirrors the +/// hover-revealed `.play-overlay` on the web cards, but always shown +/// because hover is not a real interaction on touch. +/// +/// Manages its own loading state via [_starting] so the caller's tap +/// handler can be `Future Function()` without worrying about +/// re-entrancy. Disabled state suppresses the tap (used for empty +/// playlists). +class PlayCircleButton extends StatefulWidget { + const PlayCircleButton({ + required this.onPressed, + this.enabled = true, + this.size = 44, + super.key, + }); + + /// Tap handler. Returns a Future so the button can show a spinner + /// during the play setup (fetch detail tracks, etc.). + final Future Function() onPressed; + + /// When false, the button is rendered semi-transparent and taps are + /// ignored. Empty playlists / artists with no tracks should disable. + final bool enabled; + + /// Outer diameter in logical pixels. 44 is the iOS / Android + /// touch-target minimum; matches the design doc. + final double size; + + @override + State createState() => _PlayCircleButtonState(); +} + +class _PlayCircleButtonState extends State { + bool _starting = false; + + Future _handleTap() async { + if (_starting || !widget.enabled) return; + setState(() => _starting = true); + try { + await widget.onPressed(); + } finally { + if (mounted) setState(() => _starting = false); + } + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final iconSize = widget.size * 0.5; + return Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkResponse( + onTap: widget.enabled ? _handleTap : null, + radius: widget.size / 2, + containedInkWell: true, + customBorder: const CircleBorder(), + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: fs.accent.withValues(alpha: widget.enabled ? 1.0 : 0.5), + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Color(0x66000000), + blurRadius: 6, + offset: Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: _starting + ? SizedBox( + width: iconSize, + height: iconSize, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(fs.parchment), + ), + ) + : Icon( + Icons.play_arrow, + color: fs.parchment, + size: iconSize, + ), + ), + ), + ); + } +} diff --git a/flutter_client/lib/library/widgets/track_row.dart b/flutter_client/lib/library/widgets/track_row.dart index 1c930a99..48c97e4e 100644 --- a/flutter_client/lib/library/widgets/track_row.dart +++ b/flutter_client/lib/library/widgets/track_row.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/track.dart'; +import '../../player/player_provider.dart'; import '../../shared/widgets/track_actions/track_actions_button.dart'; import '../../theme/theme_extension.dart'; import 'cached_indicator.dart'; -class TrackRow extends StatelessWidget { +class TrackRow extends ConsumerWidget { const TrackRow({ required this.track, required this.onTap, @@ -23,56 +25,75 @@ class TrackRow extends StatelessWidget { final bool actions; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0'); final secs = (track.durationSec % 60).toString().padLeft(2, '0'); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row(children: [ - if (track.trackNumber != null) - SizedBox( - width: 22, - child: Text( - track.trackNumber.toString(), - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), + // Watch the currently-playing media item so the row's accent + // highlight tracks playback state. Matches _QueueRow's visual + // treatment in queue_screen.dart so the "you are here" cue is + // consistent across album / playlist / queue surfaces. + final currentId = ref.watch(mediaItemProvider).value?.id; + final isCurrent = currentId != null && currentId == track.id; + return Container( + decoration: BoxDecoration( + color: isCurrent ? fs.iron : null, + border: isCurrent + ? Border(left: BorderSide(color: fs.accent, width: 2)) + : null, + ), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row(children: [ + if (track.trackNumber != null) + SizedBox( + width: 22, + child: Text( + track.trackNumber.toString(), + style: TextStyle(color: fs.ash, fontSize: 13), ), - // Skip the artist line entirely when empty so the row - // height collapses to a single line of title — keeps - // dense album views from looking padded. - if (track.artistName.isNotEmpty) + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ Text( - track.artistName, + track.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), + style: TextStyle( + color: isCurrent ? fs.accent : fs.parchment, + fontSize: 14, + fontWeight: + isCurrent ? FontWeight.w500 : FontWeight.w400, + ), ), - ], + // Skip the artist line entirely when empty so the row + // height collapses to a single line of title — keeps + // dense album views from looking padded. + if (track.artistName.isNotEmpty) + Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), ), - ), - CachedIndicator(trackId: track.id), - Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), - if (trailing != null) - Padding( - padding: const EdgeInsets.only(left: 8), - child: trailing!, - ), - if (actions) TrackActionsButton(track: track), - ]), + CachedIndicator(trackId: track.id), + Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), + if (trailing != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: trailing!, + ), + if (actions) TrackActionsButton(track: track), + ]), + ), ), ); } diff --git a/flutter_client/lib/models/home_index.dart b/flutter_client/lib/models/home_index.dart new file mode 100644 index 00000000..d6f4c8c8 --- /dev/null +++ b/flutter_client/lib/models/home_index.dart @@ -0,0 +1,41 @@ +/// Mirrors internal/api/types.go HomeIndexPayload. Five flat slices of +/// entity ID strings — the per-item rendering variant of HomeData. +/// Section name implies entity type; no per-entry type tag is needed. +/// +/// Slices are non-null after fromJson so callers can branch on `length` +/// instead of dealing with null sections. +class HomeIndex { + const HomeIndex({ + required this.recentlyAddedAlbums, + required this.rediscoverAlbums, + required this.rediscoverArtists, + required this.mostPlayedTracks, + required this.lastPlayedArtists, + }); + + final List recentlyAddedAlbums; + final List rediscoverAlbums; + final List rediscoverArtists; + final List mostPlayedTracks; + final List lastPlayedArtists; + + static const empty = HomeIndex( + recentlyAddedAlbums: [], + rediscoverAlbums: [], + rediscoverArtists: [], + mostPlayedTracks: [], + lastPlayedArtists: [], + ); + + factory HomeIndex.fromJson(Map j) { + List ids(String key) => + ((j[key] as List?) ?? const []).map((e) => e.toString()).toList(); + return HomeIndex( + recentlyAddedAlbums: ids('recently_added_albums'), + rediscoverAlbums: ids('rediscover_albums'), + rediscoverArtists: ids('rediscover_artists'), + mostPlayedTracks: ids('most_played_tracks'), + lastPlayedArtists: ids('last_played_artists'), + ); + } +} diff --git a/flutter_client/lib/player/album_color_extractor.dart b/flutter_client/lib/player/album_color_extractor.dart new file mode 100644 index 00000000..d1a134c8 --- /dev/null +++ b/flutter_client/lib/player/album_color_extractor.dart @@ -0,0 +1,83 @@ +// Dominant-color extraction from album cover art (#396 item 1). +// The full-screen Now Playing screen uses this to paint a top-to-bottom +// gradient backdrop that grounds each track in its album's palette. +// +// Implementation: reuses AlbumCoverCache to get a local file path for +// the cover, then runs PaletteGenerator over it. Results are cached +// in-memory keyed by album_id so back-to-back plays of the same album +// don't repeat the work. Cache is process-lifetime; album-art changes +// are rare enough that LRU eviction isn't worth the complexity. +// +// Returns null on any failure (no cover, empty album_id, palette +// extraction returned nothing). Callers fall back to the FabledSword +// obsidian color for the gradient when null. + +import 'dart:io'; + +import 'package:flutter/painting.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:palette_generator/palette_generator.dart'; + +import 'player_provider.dart'; + +/// Caches dominant colors keyed by album_id. Process-lifetime; refilled +/// on app restart. +class AlbumColorCache { + AlbumColorCache(this._ref); + + final Ref _ref; + final Map _cache = {}; + + /// Returns the dominant color for the album's cover, or null if no + /// cover is available or extraction fails. Same call for the same + /// album_id is cached after the first successful resolution. + Future getOrExtract(String albumId) async { + if (albumId.isEmpty) return null; + if (_cache.containsKey(albumId)) return _cache[albumId]; + final color = await _extract(albumId); + _cache[albumId] = color; + return color; + } + + Future _extract(String albumId) async { + try { + final coverCache = _ref.read(albumCoverCacheProvider); + final path = await coverCache.getOrFetch(albumId); + if (path == null) return null; + final file = File(path); + if (!await file.exists()) return null; + final palette = await PaletteGenerator.fromImageProvider( + FileImage(file), + // Small target size: palette extraction is CPU-bound and the + // gradient only needs a single dominant color, so we don't + // need full-resolution sampling. + size: const Size(80, 80), + maximumColorCount: 8, + ); + // Prefer the explicit dominant color; fall back to the strongest + // muted swatch (which tends to read better as a background than + // a hot vibrant pick), then any populated swatch. + final swatch = palette.dominantColor ?? + palette.darkMutedColor ?? + palette.darkVibrantColor ?? + palette.mutedColor; + return swatch?.color; + } catch (_) { + return null; + } + } +} + +final albumColorCacheProvider = Provider( + (ref) => AlbumColorCache(ref), +); + +/// Family provider: dominant color for the given album_id, or null if +/// no cover / extraction failed. The Now Playing screen watches this +/// keyed by the current MediaItem's album_id. +final albumColorProvider = FutureProvider.family( + (ref, albumId) async { + final cache = ref.watch(albumColorCacheProvider); + return cache.getOrExtract(albumId); + }, +); diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart index e04a2d64..1b0038ee 100644 --- a/flutter_client/lib/player/now_playing_screen.dart +++ b/flutter_client/lib/player/now_playing_screen.dart @@ -11,8 +11,20 @@ import '../models/track.dart'; import '../shared/widgets/server_image.dart'; import '../shared/widgets/track_actions/track_actions_button.dart'; import '../theme/theme_extension.dart'; +import 'album_color_extractor.dart'; import 'player_provider.dart'; +/// Hero tag shared between the mini player's cover and the full-screen +/// _AlbumArt's cover so tapping the mini bar animates the artwork from +/// the bar's footprint to the full-screen size. Stable per-route (not +/// keyed by media.id) so the transition works regardless of what's +/// playing. +const String kPlayerCoverHeroTag = 'player-cover'; + +/// Duration for the AnimatedSwitcher / AnimatedContainer track-change +/// crossfade. ~300ms reads as a smooth transition without dragging. +const Duration _trackChangeDuration = Duration(milliseconds: 300); + /// Full-screen player. Mounted on /now-playing. Pushed via a slide-up /// transition (see routing.dart). Dismisses three ways: /// @@ -74,6 +86,18 @@ class _NowPlayingScreenState extends ConsumerState { final actions = ref.read(playerActionsProvider); final albumId = (media.extras?['album_id'] as String?) ?? ''; + // Dominant-color gradient backdrop seeded from the current track's + // album cover. While the color resolves (or when no cover exists), + // the gradient collapses to a flat fs.obsidian — same look as + // before the polish landed. Tweens to the new color on track change + // via AnimatedContainer. + final dominantAsync = ref.watch(albumColorProvider(albumId)); + final dominant = dominantAsync.asData?.value ?? fs.obsidian; + // 0.55 alpha keeps the color present without overwhelming contrast + // on the title / artist text below. The lower half of the screen + // stays obsidian for control legibility. + final gradientTop = dominant.withValues(alpha: 0.55); + return Scaffold( backgroundColor: fs.obsidian, // The whole screen accepts vertical drag for dismissal. Buttons @@ -84,7 +108,17 @@ class _NowPlayingScreenState extends ConsumerState { onVerticalDragUpdate: _onDragUpdate, onVerticalDragEnd: _onDragEnd, behavior: HitTestBehavior.translucent, - child: SafeArea( + child: AnimatedContainer( + duration: _trackChangeDuration, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [gradientTop, fs.obsidian], + stops: const [0.0, 0.7], + ), + ), + child: SafeArea( child: Column( children: [ _TopBar(fs: fs), @@ -94,25 +128,47 @@ class _NowPlayingScreenState extends ConsumerState { child: Column( children: [ const Spacer(), - _AlbumArt(media: media, albumId: albumId, fs: fs), - const SizedBox(height: 28), - _TitleRow(media: media, fs: fs), - const SizedBox(height: 4), - Text( - media.artist ?? '', - style: TextStyle(color: fs.ash, fontSize: 14), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if ((media.album ?? '').isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - media.album!, - style: TextStyle(color: fs.ash, fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, + AnimatedSwitcher( + duration: _trackChangeDuration, + child: KeyedSubtree( + key: ValueKey('cover-${media.id}'), + child: _AlbumArt( + media: media, albumId: albumId, fs: fs), ), - ], + ), + const SizedBox(height: 28), + AnimatedSwitcher( + duration: _trackChangeDuration, + child: KeyedSubtree( + key: ValueKey('title-${media.id}'), + child: _TitleRow(media: media, fs: fs), + ), + ), + const SizedBox(height: 4), + AnimatedSwitcher( + duration: _trackChangeDuration, + child: Column( + key: ValueKey('artist-album-${media.id}'), + mainAxisSize: MainAxisSize.min, + children: [ + Text( + media.artist ?? '', + style: TextStyle(color: fs.ash, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if ((media.album ?? '').isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + media.album!, + style: TextStyle(color: fs.ash, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), const SizedBox(height: 24), _SecondaryControls( fs: fs, @@ -136,6 +192,7 @@ class _NowPlayingScreenState extends ConsumerState { ), ], ), + ), ), ), ); @@ -204,9 +261,18 @@ class _AlbumArt extends StatelessWidget { aspectRatio: 1, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: cover, + child: Hero( + tag: kPlayerCoverHeroTag, + // flightShuttleBuilder ensures the in-flight Hero renders the + // destination's cover image (not the mini bar's small one) + // during the entire animation, which reads as a smooth grow + // rather than a swap mid-flight. + flightShuttleBuilder: + (_, __, ___, ____, toHeroContext) => toHeroContext.widget, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: cover, + ), ), ), ); diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index 0e03fed3..261e8d8d 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:audio_service/audio_service.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -10,6 +11,7 @@ import '../likes/like_button.dart'; import '../models/track.dart'; import '../shared/widgets/track_actions/track_actions_button.dart'; import '../theme/theme_extension.dart'; +import 'now_playing_screen.dart' show kPlayerCoverHeroTag; import 'player_provider.dart'; /// Compact player bar mounted at the bottom of the app shell. Mini @@ -88,29 +90,45 @@ class _TrackInfo extends StatelessWidget { final fs = Theme.of(context).extension()!; final artistName = (media.artist ?? '').trim(); + // Cover is wrapped in a Hero with the shared kPlayerCoverHeroTag so + // tapping the mini bar to expand into NowPlayingScreen animates the + // artwork from this 48dp footprint to the full-screen size rather + // than fade-cutting. Tag is stable per-route (not keyed by media.id) + // so the transition works regardless of what's playing. + Widget cover; + if (media.artUri != null) { + // CachedNetworkImageProvider for HTTPS art URIs so the mini bar + // hits the same disk cache the rest of the UI uses (ServerImage, + // discover thumbnails). FileImage stays for the file:// branch + // populated by AlbumCoverCache — bytes are already on disk and a + // second cache layer would only burn duplicate space. + cover = Image( + image: media.artUri!.isScheme('file') + ? FileImage(File.fromUri(media.artUri!)) as ImageProvider + : CachedNetworkImageProvider(media.artUri.toString()), + width: 48, + height: 48, + // Without a fit, Image paints the source at its intrinsic + // resolution inside the 48dp box — a thumbnail-sized cover + // would render as a tiny inset. Cover stretches/crops to fill + // the box uniformly. + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + Container(width: 48, height: 48, color: fs.slate), + ); + } else { + cover = Container(width: 48, height: 48, color: fs.slate); + } return Row( // Default centering vertically aligns the like/kebab buttons // against the album art (48dp) — visually they span the title + // artist block instead of sitting on the title baseline. crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (media.artUri != null) - Image( - image: media.artUri!.isScheme('file') - ? FileImage(File.fromUri(media.artUri!)) as ImageProvider - : NetworkImage(media.artUri.toString()), - width: 48, - height: 48, - // Without a fit, Image paints the source at its intrinsic - // resolution inside the 48dp box — a thumbnail-sized cover - // would render as a tiny inset. Cover stretches/crops to - // fill the box uniformly. - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Container(width: 48, height: 48, color: fs.slate), - ) - else - Container(width: 48, height: 48, color: fs.slate), + Hero( + tag: kPlayerCoverHeroTag, + child: cover, + ), const SizedBox(width: 12), Expanded( child: Column( diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart index 37d7ce9a..50c4d12b 100644 --- a/flutter_client/lib/playlists/playlist_detail_screen.dart +++ b/flutter_client/lib/playlists/playlist_detail_screen.dart @@ -7,6 +7,7 @@ import '../cache/db.dart'; import '../models/playlist.dart'; import '../models/track.dart'; import '../player/player_provider.dart'; +import '../shared/live_events_provider.dart'; import '../shared/widgets/track_actions/track_actions_button.dart'; import '../theme/theme_extension.dart'; import 'playlists_provider.dart'; @@ -24,6 +25,23 @@ class PlaylistDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + // #402 wire-up: invalidate the detail provider on playlist.updated / + // .tracks_changed events whose payload matches the visible id. On + // .deleted matching this id, navigate back so the user isn't left + // staring at a gone-from-server playlist. + ref.listen>(liveEventsProvider, (_, next) { + final e = next.asData?.value; + if (e == null) return; + final eventPlaylistId = e.data['playlist_id'] as String?; + if (eventPlaylistId != id) return; + switch (e.kind) { + case 'playlist.updated': + case 'playlist.tracks_changed': + ref.invalidate(playlistDetailProvider(id)); + case 'playlist.deleted': + if (context.mounted) context.pop(); + } + }); final detail = ref.watch(playlistDetailProvider(id)); // Resolve the best playlist info available for the AppBar title. @@ -49,60 +67,110 @@ class PlaylistDetailScreen extends ConsumerWidget { overflow: TextOverflow.ellipsis, ), ), - body: detail.when( - // Loading from a seed: render the header immediately + a - // small inline "loading tracks" hint, instead of an opaque - // full-screen spinner. The body fills in when tracks arrive. - loading: () => seed == null - ? const Center(child: CircularProgressIndicator()) - : _SeedBody(playlist: seed!), - error: (e, _) => - Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (d) => _Body(detail: d), + // AnimatedSwitcher between the skeleton body and the real body + // smooths the cold-visit moment when bulk detail lands. 220ms + // matches the per-tile reveal feel used on home / liked tabs. + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOut, + child: detail.when( + loading: () => _SkeletonBody( + key: const ValueKey('skeleton'), + seed: seed, + ), + error: (e, _) => Center( + key: const ValueKey('error'), + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (d) => _Body(key: const ValueKey('body'), detail: d), + ), ), ); } } -/// Loading-state body: renders just the header from the seed -/// playlist + a "loading tracks…" placeholder. CTA buttons are -/// hidden until the real track list arrives (they need playable -/// refs to do anything useful). -class _SeedBody extends StatelessWidget { - const _SeedBody({required this.playlist}); - final Playlist playlist; +/// Cold-visit body: header from the seed (if any) + N skeleton rows. +/// N comes from the seed's trackCount so the row count matches the +/// real list when it lands — no layout jump on swap. Without a seed +/// (deep link straight to a playlist with no prior cache) the +/// skeleton renders a small default and grows when real data arrives. +class _SkeletonBody extends StatelessWidget { + const _SkeletonBody({super.key, this.seed}); + final Playlist? seed; + + static const _defaultSkeletonCount = 8; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; - return ListView(children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (playlist.description.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - playlist.description, - style: TextStyle(color: fs.ash, fontSize: 13), - ), + final count = seed?.trackCount ?? _defaultSkeletonCount; + return ListView.builder( + itemCount: count + 1, // +1 for header + itemBuilder: (ctx, i) { + if (i == 0) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (seed != null && seed!.description.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + seed!.description, + style: TextStyle(color: fs.ash, fontSize: 13), + ), + ), + Text( + seed == null + ? 'Loading…' + : '${seed!.trackCount} ${seed!.trackCount == 1 ? "track" : "tracks"}', + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), + ); + } + return const _PlaylistTrackSkeleton(); + }, + ); + } +} + +/// Skeleton matched to _PlaylistTrackRow's layout (no cover image — +/// playlist rows are text-only). Two text-shaped placeholders for +/// title + secondary line, plus a duration block on the right. +class _PlaylistTrackSkeleton extends StatelessWidget { + const _PlaylistTrackSkeleton(); + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 200, height: 14, color: fs.slate), + const SizedBox(height: 6), + Container(width: 140, height: 12, color: fs.slate), + ], ), - Text( - '${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}', - style: TextStyle(color: fs.ash, fontSize: 12), ), - ]), + const SizedBox(width: 16), + Container(width: 32, height: 12, color: fs.slate), + ], ), - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Center(child: CircularProgressIndicator()), - ), - ]); + ); } } class _Body extends ConsumerWidget { - const _Body({required this.detail}); + const _Body({super.key, required this.detail}); final PlaylistDetail detail; @override @@ -233,51 +301,69 @@ class _Header extends ConsumerWidget { } } -class _PlaylistTrackRow extends StatelessWidget { +class _PlaylistTrackRow extends ConsumerWidget { const _PlaylistTrackRow({required this.row, required this.onTap}); final PlaylistTrack row; final VoidCallback? onTap; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0'); final secs = (row.durationSec % 60).toString().padLeft(2, '0'); - final color = row.isAvailable ? fs.parchment : fs.ash; - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - row.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: color, - fontSize: 14, - decoration: row.isAvailable - ? null - : TextDecoration.lineThrough, + // "Now playing" highlight — matches _QueueRow and TrackRow so the + // user sees which playlist row is current without reading the + // player bar. Unavailable rows never match. + final currentId = ref.watch(mediaItemProvider).value?.id; + final isCurrent = row.isAvailable && + currentId != null && + currentId == row.trackId; + final baseColor = row.isAvailable ? fs.parchment : fs.ash; + final titleColor = isCurrent ? fs.accent : baseColor; + return Container( + decoration: BoxDecoration( + color: isCurrent ? fs.iron : null, + border: isCurrent + ? Border(left: BorderSide(color: fs.accent, width: 2)) + : null, + ), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + row.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: titleColor, + fontSize: 14, + fontWeight: + isCurrent ? FontWeight.w500 : FontWeight.w400, + decoration: row.isAvailable + ? null + : TextDecoration.lineThrough, + ), ), - ), - Text( - '${row.artistName} · ${row.albumTitle}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], + Text( + '${row.artistName} · ${row.albumTitle}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), ), - ), - Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), - if (row.trackId != null) - TrackActionsButton(track: _toTrackRef(row)), - ]), + Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), + if (row.trackId != null) + TrackActionsButton(track: _toTrackRef(row)), + ]), + ), ), ); } diff --git a/flutter_client/lib/playlists/widgets/playlist_card.dart b/flutter_client/lib/playlists/widgets/playlist_card.dart index 515bd55d..47e75033 100644 --- a/flutter_client/lib/playlists/widgets/playlist_card.dart +++ b/flutter_client/lib/playlists/widgets/playlist_card.dart @@ -1,21 +1,29 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../library/widgets/play_circle_button.dart'; import '../../models/playlist.dart'; +import '../../models/track.dart'; +import '../../player/player_provider.dart'; import '../../shared/widgets/server_image.dart'; import '../../theme/theme_extension.dart'; +import '../playlists_provider.dart'; /// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp), /// name + optional system-variant badge below. Tap pushes -/// `/playlists/{id}`. -class PlaylistCard extends StatelessWidget { +/// `/playlists/{id}`. The bottom-right play button fetches the +/// playlist and starts playback from track 0; disabled when the +/// playlist has no tracks. +class PlaylistCard extends ConsumerWidget { const PlaylistCard({super.key, required this.playlist}); final Playlist playlist; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + final hasTracks = playlist.trackCount > 0; return SizedBox( width: 176, child: Material( @@ -26,26 +34,39 @@ class PlaylistCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 144, - height: 144, - color: fs.slate, - // coverUrl is deterministic per playlist (see - // CachedPlaylistAdapter.toRef). When the server's - // collage isn't built yet, ServerImage's - // errorBuilder shows the queue_music icon over the - // slate background. - child: playlist.coverUrl.isEmpty - ? Icon(Icons.queue_music, color: fs.ash, size: 56) - : ServerImage( - url: playlist.coverUrl, - fit: BoxFit.cover, - fallback: - Icon(Icons.queue_music, color: fs.ash, size: 56), - ), - ), + // Stack: collage + overlaid play button at bottom-right. + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Container( + width: 144, + height: 144, + color: fs.slate, + // coverUrl is deterministic per playlist (see + // CachedPlaylistAdapter.toRef). When the server's + // collage isn't built yet, ServerImage's + // errorBuilder shows the queue_music icon over the + // slate background. + child: playlist.coverUrl.isEmpty + ? Icon(Icons.queue_music, color: fs.ash, size: 56) + : ServerImage( + url: playlist.coverUrl, + fit: BoxFit.cover, + fallback: + Icon(Icons.queue_music, color: fs.ash, size: 56), + ), + ), + ), + Positioned( + bottom: 6, + right: 6, + child: PlayCircleButton( + enabled: hasTracks, + onPressed: () => _playPlaylist(ref), + ), + ), + ], ), const SizedBox(height: 8), Text( @@ -76,4 +97,29 @@ class PlaylistCard extends StatelessWidget { ), ); } + + /// Fetches the playlist via /api/playlists/{id}, materializes each + /// PlaylistTrack into a TrackRef (filtering out unavailable rows + /// whose `trackId` is null after a track-delete), and plays from + /// index 0. Mirrors the web PlaylistCard's onPlayClick. + Future _playPlaylist(WidgetRef ref) async { + final api = await ref.read(playlistsApiProvider.future); + final detail = await api.get(playlist.id); + final refs = []; + for (final t in detail.tracks) { + if (t.trackId == null) continue; + refs.add(TrackRef( + id: t.trackId!, + title: t.title, + albumId: t.albumId ?? '', + albumTitle: t.albumTitle, + artistId: t.artistId ?? '', + artistName: t.artistName, + durationSec: t.durationSec, + streamUrl: t.streamUrl ?? '', + )); + } + if (refs.isEmpty) return; + await ref.read(playerActionsProvider).playTracks(refs, initialIndex: 0); + } } diff --git a/flutter_client/lib/quarantine/quarantine_provider.dart b/flutter_client/lib/quarantine/quarantine_provider.dart index 3bd1a902..4ca2b3d6 100644 --- a/flutter_client/lib/quarantine/quarantine_provider.dart +++ b/flutter_client/lib/quarantine/quarantine_provider.dart @@ -1,7 +1,13 @@ +import 'dart:async'; + +import 'package:drift/drift.dart' as drift; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/endpoints/me.dart'; import '../api/endpoints/quarantine.dart'; +import '../cache/audio_cache_manager.dart' show appDbProvider; +import '../cache/connectivity_provider.dart'; +import '../cache/db.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/quarantine_mine.dart'; import '../models/track.dart'; @@ -10,58 +16,166 @@ final quarantineApiProvider = FutureProvider((ref) async { return QuarantineApi(await ref.watch(dioProvider.future)); }); +/// Drift-first ("hidden") tab controller. Reads from cached_quarantine_ +/// mine via a drift watch() subscription set up in build(), so any drift +/// mutation (incoming sync, optimistic flag/unflag, SSE-triggered +/// refresh) re-emits the list automatically. +/// +/// API surface unchanged from the prior FutureProvider-backed controller +/// — call sites still do `ref.read(myQuarantineProvider.notifier).flag()` +/// / `.unflag()` / `.isHidden()`. Internal storage moved from in-memory +/// AsyncNotifier state to drift so the Hidden tab paints from disk on +/// cold open and the quarantine list is queryable offline. class MyQuarantineController extends AsyncNotifier> { @override Future> build() async { - final dio = await ref.watch(dioProvider.future); - return MeApi(dio).quarantineMine(); + final db = ref.watch(appDbProvider); + + // Subscribe to drift first so any mutation (incoming sync, flag/ + // unflag, etc.) propagates without needing a manual invalidate. + // Disposed when the provider rebuilds or the listener detaches. + final sub = db.select(db.cachedQuarantineMine).watch().listen((rows) { + state = AsyncData(rows.map(_rowToModel).toList()); + }); + ref.onDispose(sub.cancel); + + // SWR refresh on every build so the freshest server-side state + // overtakes drift in the background. Don't await — UI gets the + // cached snapshot first, freshness lands later via the watch(). + unawaited(_refreshFromServer()); + + final initial = await db.select(db.cachedQuarantineMine).get(); + return initial.map(_rowToModel).toList(); + } + + /// Hits /api/quarantine/mine and replaces the local table with the + /// authoritative server set. Best-effort: offline / 5xx / unresolved + /// dependencies (e.g. uninitialised serverUrl in tests) all collapse + /// to a no-op — drift stays on the prior cached state. + Future _refreshFromServer() async { + try { + final online = await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true); + if (!online) return; + final dio = await ref.read(dioProvider.future); + final fresh = await MeApi(dio).quarantineMine(); + final db = ref.read(appDbProvider); + await db.transaction(() async { + // Full replace — quarantine list is small and we want server + // unflags from other devices to take effect. Doing this in a + // transaction means the watch() sees exactly one emission with + // the merged state, not a delete-then-insert flicker. + await db.delete(db.cachedQuarantineMine).go(); + for (final r in fresh) { + await db + .into(db.cachedQuarantineMine) + .insertOnConflictUpdate(_modelToCompanion(r)); + } + }); + } catch (_) { + // Swallow — cached state stays visible. + } } /// True when this track is in the caller's quarantine list. bool isHidden(String trackId) => (state.value ?? const []).any((r) => r.trackId == trackId); - /// Optimistic flag: prepends a synthetic row, calls server, - /// rolls back on error. + /// Optimistic flag: inserts the synthetic row into drift (watch fires + /// immediately so the UI updates), calls server, rolls back on error. Future flag(TrackRef track, String reason, String notes) async { - final api = await ref.read(quarantineApiProvider.future); - final current = state.value ?? const []; - if (current.any((r) => r.trackId == track.id)) return; // already hidden - final synthetic = QuarantineMineRow( + final db = ref.read(appDbProvider); + final existed = await (db.select(db.cachedQuarantineMine) + ..where((t) => t.trackId.equals(track.id))) + .getSingleOrNull(); + if (existed != null) return; // already hidden + + final companion = CachedQuarantineMineCompanion.insert( trackId: track.id, reason: reason, - notes: notes.isEmpty ? null : notes, + notes: notes.isEmpty + ? const drift.Value.absent() + : drift.Value(notes), createdAt: DateTime.now().toUtc().toIso8601String(), trackTitle: track.title, - trackDurationMs: track.durationSec * 1000, + trackDurationMs: drift.Value(track.durationSec * 1000), albumId: track.albumId, albumTitle: track.albumTitle, artistId: track.artistId, artistName: track.artistName, ); - state = AsyncData([synthetic, ...current]); + await db.into(db.cachedQuarantineMine).insertOnConflictUpdate(companion); + try { + final api = await ref.read(quarantineApiProvider.future); await api.flag(track.id, reason, notes: notes); } catch (e, st) { - state = AsyncData(current); + // Rollback the optimistic insert; watch() re-emits the prior set. + await (db.delete(db.cachedQuarantineMine) + ..where((t) => t.trackId.equals(track.id))) + .go(); Error.throwWithStackTrace(e, st); } } - /// Optimistic unflag: removes the row, calls server, rolls back on error. + /// Optimistic unflag: removes the drift row, calls server, restores + /// on error. Future unflag(String trackId) async { - final api = await ref.read(quarantineApiProvider.future); - final current = state.value ?? const []; - final removed = current.where((r) => r.trackId == trackId).toList(); - if (removed.isEmpty) return; - state = AsyncData(current.where((r) => r.trackId != trackId).toList()); + final db = ref.read(appDbProvider); + final existing = await (db.select(db.cachedQuarantineMine) + ..where((t) => t.trackId.equals(trackId))) + .getSingleOrNull(); + if (existing == null) return; + + await (db.delete(db.cachedQuarantineMine) + ..where((t) => t.trackId.equals(trackId))) + .go(); + try { + final api = await ref.read(quarantineApiProvider.future); await api.unflag(trackId); } catch (e, st) { - state = AsyncData(current); + // Restore the row we just deleted — toCompanion(true) preserves + // every field including the original fetchedAt timestamp. + await db + .into(db.cachedQuarantineMine) + .insertOnConflictUpdate(existing.toCompanion(true)); Error.throwWithStackTrace(e, st); } } + + static QuarantineMineRow _rowToModel(CachedQuarantineMineData r) => + QuarantineMineRow( + trackId: r.trackId, + reason: r.reason, + notes: r.notes, + createdAt: r.createdAt, + trackTitle: r.trackTitle, + trackDurationMs: r.trackDurationMs, + albumId: r.albumId, + albumTitle: r.albumTitle, + albumCoverArtPath: r.albumCoverArtPath, + artistId: r.artistId, + artistName: r.artistName, + ); + + static CachedQuarantineMineCompanion _modelToCompanion(QuarantineMineRow r) => + CachedQuarantineMineCompanion.insert( + trackId: r.trackId, + reason: r.reason, + notes: r.notes == null ? const drift.Value.absent() : drift.Value(r.notes), + createdAt: r.createdAt, + trackTitle: r.trackTitle, + trackDurationMs: drift.Value(r.trackDurationMs), + albumId: r.albumId, + albumTitle: r.albumTitle, + albumCoverArtPath: r.albumCoverArtPath == null + ? const drift.Value.absent() + : drift.Value(r.albumCoverArtPath), + artistId: r.artistId, + artistName: r.artistName, + ); } final myQuarantineProvider = diff --git a/flutter_client/lib/requests/requests_screen.dart b/flutter_client/lib/requests/requests_screen.dart index 0eb8f19a..2cf98fdd 100644 --- a/flutter_client/lib/requests/requests_screen.dart +++ b/flutter_client/lib/requests/requests_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../models/admin_request.dart'; +import '../shared/live_events_provider.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'requests_provider.dart'; @@ -13,6 +14,16 @@ class RequestsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + // #402 wire-up: refresh the user's own requests list on + // request.status_changed. Server-side events are user-scoped + // (admin actions on someone else's request route to that other + // user's stream); the dispatcher's filter already drops mismatches. + ref.listen>(liveEventsProvider, (_, next) { + final e = next.asData?.value; + if (e?.kind == 'request.status_changed') { + ref.invalidate(myRequestsProvider); + } + }); final requests = ref.watch(myRequestsProvider); return Scaffold( diff --git a/flutter_client/lib/shared/live_events_dispatcher.dart b/flutter_client/lib/shared/live_events_dispatcher.dart new file mode 100644 index 00000000..28bc65f0 --- /dev/null +++ b/flutter_client/lib/shared/live_events_dispatcher.dart @@ -0,0 +1,104 @@ +// Maps incoming LiveEvent kinds to provider invalidations. +// +// Activated by ref.read(liveEventsDispatcherProvider) in app.dart; once +// activated, the dispatcher listens to liveEventsProvider for the +// lifetime of the ProviderScope and invalidates the small set of +// public-scoped providers we know about. +// +// Screen-scoped providers (file-private providers in library_screen.dart, +// admin handlers, etc.) opt in to live-refresh by themselves listening +// to liveEventsProvider — the dispatcher only handles cross-screen, +// publicly-importable providers. This keeps the dispatcher small and +// avoids a back-edge dependency from /shared onto every feature folder. +// +// Includes an AppLifecycleState resume handler that defensively +// invalidates the same set on app foreground. SSE will catch up on its +// own, but on cold-start or after a long background period the +// re-invalidate is fast and avoids stale data flashing before the +// stream reconnects. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../library/library_providers.dart' show homeProvider; +import '../quarantine/quarantine_provider.dart'; +import 'live_events_provider.dart'; + +/// Activates the SSE → invalidation dispatcher for the lifetime of the +/// containing ProviderScope. Read this once at startup (from app.dart); +/// the provider's body runs once, sets up listeners, and returns a +/// sentinel value. +final liveEventsDispatcherProvider = Provider<_LiveEventsDispatcher>((ref) { + final d = _LiveEventsDispatcher(ref); + ref.onDispose(d.dispose); + return d; +}); + +class _LiveEventsDispatcher with WidgetsBindingObserver { + _LiveEventsDispatcher(this._ref) { + // Subscribe to the event stream. Each emitted event invokes + // _handle. Errors / disconnects auto-rebuild the underlying + // provider; we don't need to react to them here. + _sub = _ref.listen>( + liveEventsProvider, + (_, next) => next.whenData(_handle), + fireImmediately: false, + ); + WidgetsBinding.instance.addObserver(this); + } + + final Ref _ref; + late final ProviderSubscription> _sub; + + void _handle(LiveEvent e) { + // Map event kinds to provider invalidations. Keep this list short: + // only providers reachable from /shared. Screen-private providers + // listen to liveEventsProvider themselves. + switch (e.kind) { + case 'quarantine.flagged': + case 'quarantine.unflagged': + case 'quarantine.resolved': + case 'quarantine.file_deleted': + case 'quarantine.deleted_via_lidarr': + _ref.invalidate(myQuarantineProvider); + // Hidden / liked / album rows referencing a deleted track may + // now be stale — homeProvider re-fetch refreshes the cards. + _ref.invalidate(homeProvider); + case 'playlist.created': + case 'playlist.updated': + case 'playlist.deleted': + case 'playlist.tracks_changed': + // Home renders a Playlists row; refresh it so a delete or add + // is reflected immediately. Detail screens that need the + // mutated row will get a separate invalidate from their own + // listener (filed as a follow-up). + _ref.invalidate(homeProvider); + case 'scan.run_started': + case 'scan.run_finished': + // Admin scan card provider lives in the admin feature folder + // and is file-private today; will be invalidated by the admin + // dashboard's own listener once that exists. + break; + default: + // track.liked / track.unliked / request.status_changed reach + // screen-private providers; their screens listen directly. + break; + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + // Defensive cold-start invalidation. SSE will catch up on its + // own, but the re-invalidate flushes any stale data that + // landed while the app was backgrounded. + _ref.invalidate(myQuarantineProvider); + _ref.invalidate(homeProvider); + } + } + + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _sub.close(); + } +} diff --git a/flutter_client/lib/shared/live_events_provider.dart b/flutter_client/lib/shared/live_events_provider.dart new file mode 100644 index 00000000..22b840bd --- /dev/null +++ b/flutter_client/lib/shared/live_events_provider.dart @@ -0,0 +1,126 @@ +// Subscribes to the server's Server-Sent Events stream +// (GET /api/events/stream, see Fable #392) and exposes a parsed event +// stream as a Riverpod StreamProvider. Consumers wire invalidation +// behavior in live_events_dispatcher.dart. +// +// On disconnect (network blip, server restart, token rotation), the +// provider's StreamController completes; the dispatcher's auto-rebuild +// when the auth state changes re-subscribes. Explicit exponential +// backoff lives at the dispatcher layer so we don't re-create the dio +// connection too aggressively. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../auth/auth_provider.dart'; +import '../library/library_providers.dart' show dioProvider; + +/// Parsed event from the server's SSE stream. `kind` follows +/// "domain.action" naming; `data` carries the payload map. +class LiveEvent { + const LiveEvent({required this.kind, required this.userId, required this.data}); + + final String kind; + final String userId; + final Map data; + + @override + String toString() => 'LiveEvent($kind, user=$userId, data=$data)'; +} + +/// Streams parsed LiveEvent values from /api/events/stream. Errors and +/// disconnects surface as stream errors; the dispatcher decides whether +/// to retry. +final liveEventsProvider = StreamProvider((ref) async* { + // Gate the subscription on having both a server URL and a session + // token. If either is missing, emit nothing and let the provider + // auto-rebuild when auth state lands. + final token = await ref.watch(sessionTokenProvider.future); + if (token == null || token.isEmpty) { + return; + } + final dio = await ref.watch(dioProvider.future); + + final controller = StreamController(); + ref.onDispose(controller.close); + + // ignore: unawaited_futures + _runSubscription(dio, controller); + + yield* controller.stream; +}); + +/// Runs the dio streaming request and pushes parsed events into +/// [controller]. Closes the controller when the stream ends or errors. +Future _runSubscription(Dio dio, StreamController controller) async { + try { + final resp = await dio.get( + '/api/events/stream', + options: Options( + responseType: ResponseType.stream, + // No timeout — the server emits 15s heartbeats; idle timeouts + // on the client side would tear down a healthy connection. + receiveTimeout: Duration.zero, + headers: const {'Accept': 'text/event-stream'}, + ), + ); + + // SSE frames are delimited by blank lines. Accumulate raw bytes + // into a string buffer; flush parsed events on each "\n\n". + var buffer = ''; + await for (final chunk in resp.data!.stream) { + buffer += utf8.decode(chunk, allowMalformed: true); + while (true) { + final i = buffer.indexOf('\n\n'); + if (i < 0) break; + final frame = buffer.substring(0, i); + buffer = buffer.substring(i + 2); + final event = _parseFrame(frame); + if (event != null && !controller.isClosed) { + controller.add(event); + } + } + } + if (!controller.isClosed) { + await controller.close(); + } + } catch (e, st) { + if (!controller.isClosed) { + controller.addError(e, st); + await controller.close(); + } + } +} + +/// Parses one SSE frame. Heartbeat comments (starting with ":") and +/// frames without a `data:` line return null. Frames with a `data:` +/// payload that doesn't parse as JSON are also dropped (logged at +/// debug level by the caller if needed). +LiveEvent? _parseFrame(String frame) { + String? kind; + String? dataLine; + for (final line in frame.split('\n')) { + if (line.isEmpty || line.startsWith(':')) { + continue; + } + if (line.startsWith('event:')) { + kind = line.substring(6).trim(); + } else if (line.startsWith('data:')) { + dataLine = line.substring(5).trim(); + } + } + if (dataLine == null) return null; + try { + final decoded = jsonDecode(dataLine) as Map; + return LiveEvent( + kind: kind ?? (decoded['kind'] as String? ?? ''), + userId: decoded['user_id'] as String? ?? '', + data: (decoded['data'] as Map?) ?? const {}, + ); + } catch (_) { + return null; + } +} diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index cc29e387..ef14e7a2 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -151,6 +151,11 @@ class _ShellWithPlayerBar extends ConsumerWidget { final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null; return Column( children: [ + // VersionTooOldBanner above UpdateBanner: a server-rejects-you + // soft warning carries more user-relevant urgency than the APK + // download prompt below it. The two banners can coexist + // (server says you're too old AND a local APK is queued). + const VersionTooOldBanner(), const UpdateBanner(), Expanded( child: hasBanner diff --git a/flutter_client/lib/shared/widgets/server_image.dart b/flutter_client/lib/shared/widgets/server_image.dart index be44538e..88d07c4d 100644 --- a/flutter_client/lib/shared/widgets/server_image.dart +++ b/flutter_client/lib/shared/widgets/server_image.dart @@ -1,9 +1,10 @@ +import 'package:cached_network_image/cached_network_image.dart'; 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. +/// CachedNetworkImage 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] @@ -12,6 +13,11 @@ import '../../auth/auth_provider.dart'; /// /// Mirrors the absolute-or-relative logic the audio handler uses for /// stream URLs, so cover art and audio resolve URLs the same way. +/// +/// Uses CachedNetworkImage so cover bytes land on disk (path_provider +/// temp dir, keyed by URL) and survive scroll-off + app restart. The +/// previous Image.network behavior cached only in-memory, so the cold- +/// start home grid re-downloaded every cover. class ServerImage extends ConsumerWidget { const ServerImage({ super.key, @@ -31,17 +37,18 @@ class ServerImage extends ConsumerWidget { final base = ref.watch(serverUrlProvider).value; final resolved = _resolve(base, url); if (resolved == null) return empty; - // Cover endpoints are gated by RequireUser server-side. Image.network - // doesn't carry cookies/Bearer tokens automatically the way the - // browser's tag does, so we forward the session token as a - // header. Without this, the server returns 401 and the image fails. + // Cover endpoints are gated by RequireUser server-side. The image + // loader doesn't carry cookies/Bearer tokens automatically the way + // the browser's tag does, so we forward the session token as + // a header. Without this, the server returns 401 and the image + // fails. // // sessionTokenProvider is a FutureProvider — on first read after a // hot restart its .value is null until the secure-storage read - // resolves. If we fired Image.network with headers:null at that - // moment, the network image cache would lock in the 401 response - // and never retry. Instead, hold the fallback until the token is - // available, then mount the Image with the auth header attached. + // resolves. If we mounted the image with httpHeaders:null at that + // moment, the disk cache would lock in the 401 response and never + // retry. Hold the fallback until the token is available, then + // mount the image with the auth header attached. final tokenAsync = ref.watch(sessionTokenProvider); return tokenAsync.when( loading: () => empty, @@ -49,14 +56,21 @@ class ServerImage extends ConsumerWidget { data: (token) { final headers = (token != null && token.isNotEmpty) ? {'Authorization': 'Bearer $token'} - : null; - return Image.network( - resolved, + : {}; + return CachedNetworkImage( + imageUrl: resolved, + httpHeaders: headers, fit: fit, - headers: headers, + // 120ms feels like cover bytes settling in on a cache miss + // (smoother than the abrupt zero-fade); on cache hits the + // image is decoded synchronously so the fade is imperceptible. + // The default 500ms is too long — looks like a regression on + // a populated grid. + fadeInDuration: const Duration(milliseconds: 120), + fadeOutDuration: Duration.zero, // Keep failures local — a single 401/timeout shouldn't dump // a stack trace or replace the parent Container's background. - errorBuilder: (_, __, ___) => empty, + errorWidget: (_, __, ___) => empty, ); }, ); diff --git a/flutter_client/lib/shared/widgets/skeletons.dart b/flutter_client/lib/shared/widgets/skeletons.dart new file mode 100644 index 00000000..edbbbbf8 --- /dev/null +++ b/flutter_client/lib/shared/widgets/skeletons.dart @@ -0,0 +1,199 @@ +// Skeleton placeholder widgets for the per-item rendering architecture +// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md). +// +// Each skeleton matches the exact dimensions of its corresponding real +// card so the layout doesn't shift when content lands. A subtle +// shimmer sweep makes the page feel alive while individual tiles +// hydrate against drift / REST in the background. +// +// Self-contained shimmer (no `shimmer` package dep) so we can keep +// pubspec lean and tune the sweep colors against FabledSword tokens. + +import 'package:flutter/material.dart'; + +import '../../theme/theme_extension.dart'; + +/// Period of one full shimmer sweep across the skeleton. +const Duration _shimmerPeriod = Duration(milliseconds: 1200); + +/// Wraps a child with a slow-moving highlight band on top of the +/// skeleton's base color. Cheap — uses a single AnimationController +/// per surface and a LinearGradient shader. +class _Shimmer extends StatefulWidget { + const _Shimmer({required this.child}); + final Widget child; + + @override + State<_Shimmer> createState() => _ShimmerState(); +} + +class _ShimmerState extends State<_Shimmer> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl = AnimationController( + vsync: this, + duration: _shimmerPeriod, + )..repeat(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return AnimatedBuilder( + animation: _ctrl, + builder: (context, child) { + return ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (rect) { + // Sweep starts off-screen left, ends off-screen right. + final dx = (_ctrl.value * 2 - 1) * rect.width; + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + fs.slate, + fs.iron, + fs.slate, + ], + stops: const [0.0, 0.5, 1.0], + transform: _SlideGradient(dx), + ).createShader(rect); + }, + child: child, + ); + }, + child: widget.child, + ); + } +} + +/// Helper for translating a gradient horizontally inside its rect. +class _SlideGradient extends GradientTransform { + const _SlideGradient(this.dx); + final double dx; + + @override + Matrix4 transform(Rect bounds, {TextDirection? textDirection}) { + return Matrix4.translationValues(dx, 0, 0); + } +} + +/// Skeleton matched to AlbumCard's 140px outer width / 124px cover / +/// title + artist text rows. Used in horizontal carousels where the +/// real card lives. +class SkeletonAlbumTile extends StatelessWidget { + const SkeletonAlbumTile({super.key, this.width = 140}); + final double width; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final coverSize = width - 16; + return _Shimmer( + child: SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Container( + width: coverSize, + height: coverSize, + color: fs.slate, + ), + ), + const SizedBox(height: 8), + Container(width: coverSize * 0.8, height: 14, color: fs.slate), + const SizedBox(height: 4), + Container(width: coverSize * 0.6, height: 12, color: fs.slate), + ], + ), + ), + ), + ); + } +} + +/// Skeleton matched to ArtistCard's 140px / 124px round-thumb shape. +class SkeletonArtistTile extends StatelessWidget { + const SkeletonArtistTile({super.key, this.width = 140}); + final double width; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final coverSize = width - 16; + return _Shimmer( + child: SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + color: fs.slate, + shape: BoxShape.circle, + ), + ), + const SizedBox(height: 8), + Container(width: coverSize * 0.7, height: 14, color: fs.slate), + ], + ), + ), + ), + ); + } +} + +/// Skeleton matched to TrackRow's vertical-list layout. 56dp cover + +/// title + artist line, similar to the real row. +class SkeletonTrackRow extends StatelessWidget { + const SkeletonTrackRow({super.key}); + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return _Shimmer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: fs.slate, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 180, height: 14, color: fs.slate), + const SizedBox(height: 6), + Container(width: 120, height: 12, color: fs.slate), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_client/lib/shared/widgets/version_gate.dart b/flutter_client/lib/shared/widgets/version_gate.dart index 6c78e5dc..2d001910 100644 --- a/flutter_client/lib/shared/widgets/version_gate.dart +++ b/flutter_client/lib/shared/widgets/version_gate.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -6,61 +9,255 @@ import 'package:pub_semver/pub_semver.dart'; import '../../api/client.dart'; import '../../api/endpoints/health.dart'; import '../../auth/auth_provider.dart'; +import '../../theme/theme_extension.dart'; -final _versionCheckProvider = FutureProvider<_VersionResult>((ref) async { - final url = await ref.watch(serverUrlProvider.future); - if (url == null) return _VersionResult.skipped; - final dio = ApiClient.buildDio(baseUrl: url, tokenResolver: () async => null); - final body = await HealthApi(dio).check(); - final min = body['min_client_version']; - if (min == null || min.isEmpty) return _VersionResult.skipped; - final info = await PackageInfo.fromPlatform(); - final mine = Version.parse(info.version); - final required = Version.parse(min); - return mine < required ? _VersionResult.tooOld : _VersionResult.ok; -}); +/// Result of the most recent /healthz version compatibility check. +/// +/// `skipped` means the server didn't emit a `min_client_version` field +/// (older servers, partial deploys). Treat as compatible. +enum VersionResult { ok, tooOld, skipped } -enum _VersionResult { ok, tooOld, skipped } +VersionResult _resultFromString(String? s) { + switch (s) { + case 'ok': + return VersionResult.ok; + case 'tooOld': + return VersionResult.tooOld; + default: + return VersionResult.skipped; + } +} -class VersionGate extends ConsumerWidget { +String _stringFromResult(VersionResult r) { + switch (r) { + case VersionResult.ok: + return 'ok'; + case VersionResult.tooOld: + return 'tooOld'; + case VersionResult.skipped: + return 'skipped'; + } +} + +/// Drives the version-compatibility check against /healthz. Non-blocking: +/// the controller hydrates from a 1h-throttled cache on boot, exposes +/// the current `VersionResult`, and refreshes itself in the background. +/// UI (VersionGate + _VersionTooOldBanner) reads this state to decide +/// whether to surface the "too old" banner — never blocks rendering. +/// +/// Cache keys live in flutter_secure_storage so the cadence survives +/// app restarts. 1h throttle bounds /healthz traffic; explicit +/// `recheck()` (from the banner's "Check now" button or app resume) +/// bypasses the throttle. +class VersionCheckController extends AsyncNotifier { + static const _kResult = 'version_check_result'; + static const _kAtMs = 'version_check_at_ms'; + // 1 minute. The /healthz response is sub-1KB and the call is + // non-blocking, so a tight cadence buys faster recovery from + // server-side min_client_version bumps without measurable cost. The + // gate also acts as a safety net against duplicate calls when the + // periodic timer and a resume event fire close together. + static const _staleMs = 60 * 1000; + + @override + Future build() async { + final storage = ref.read(secureStorageProvider); + final resultStr = await storage.read(key: _kResult); + final atStr = await storage.read(key: _kAtMs); + final cached = _resultFromString(resultStr); + final at = int.tryParse(atStr ?? '') ?? 0; + final nowMs = DateTime.now().millisecondsSinceEpoch; + + // Fire background recheck on cold-start when cache is missing or stale. + // Doesn't block: build returns the cached result immediately; the + // recheck updates state asynchronously when it lands. + if (nowMs - at > _staleMs) { + Future.microtask(_runCheck); + } + return cached; + } + + /// Force a fresh check, bypassing the 1h staleness gate. Used by the + /// "Check now" banner button and (indirectly) by the AppLifecycleState + /// resume observer in VersionGate. + Future recheck() async => _runCheck(); + + /// Recheck only if the cache is older than the 1h throttle. No-op + /// when fresh. Used on app resume to avoid hammering /healthz when + /// the user is briefly switching between apps. + Future recheckIfStale() async { + final storage = ref.read(secureStorageProvider); + final atStr = await storage.read(key: _kAtMs); + final at = int.tryParse(atStr ?? '') ?? 0; + if (DateTime.now().millisecondsSinceEpoch - at > _staleMs) { + await _runCheck(); + } + } + + Future _runCheck() async { + try { + final url = await ref.read(serverUrlProvider.future); + if (url == null || url.isEmpty) return; + // Bounded timeouts for a health probe — the default + // ApiClient.buildDio dio is tuned for actual data fetches + // (8s connect + 30s receive). /healthz should resolve in + // tens of milliseconds; failing fast unblocks slow networks. + final dio = ApiClient.buildDio( + baseUrl: url, + tokenResolver: () async => null, + ); + dio.options.connectTimeout = const Duration(seconds: 3); + dio.options.receiveTimeout = const Duration(seconds: 2); + final body = await HealthApi(dio).check(); + final min = body['min_client_version']; + VersionResult result; + if (min == null || min.isEmpty) { + result = VersionResult.skipped; + } else { + final info = await PackageInfo.fromPlatform(); + final mine = Version.parse(info.version); + final required = Version.parse(min); + result = mine < required ? VersionResult.tooOld : VersionResult.ok; + } + // Persist + flip state. Storage write before state assignment so + // a hot reload immediately after won't see a fresh state with a + // stale cache. + final storage = ref.read(secureStorageProvider); + await storage.write(key: _kResult, value: _stringFromResult(result)); + await storage.write( + key: _kAtMs, + value: DateTime.now().millisecondsSinceEpoch.toString(), + ); + state = AsyncData(result); + } on DioException catch (_) { + // Network error / timeout. Keep the cached state; don't bump the + // timestamp so the next staleness check still triggers a retry. + } catch (_) { + // Other errors (PackageInfo, parsing, etc.). Same handling — + // soft-fail so the UI never sees an error state from a defensive + // background check. + } + } +} + +final versionCheckProvider = + AsyncNotifierProvider( + VersionCheckController.new, +); + +/// Wraps the shell. Non-blocking: always renders the child. Activates +/// the version check controller on mount and reruns it on app resume +/// (gated by the controller's 1h staleness throttle). +class VersionGate extends ConsumerStatefulWidget { const VersionGate({required this.child, super.key}); final Widget child; @override - Widget build(BuildContext context, WidgetRef ref) { - return ref.watch(_versionCheckProvider).when( - data: (r) => r == _VersionResult.tooOld ? const _TooOldScreen() : child, - error: (_, __) => child, // soft-fail if /healthz is unreachable; let the rest of the flow surface a real error - loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), - ); - } + ConsumerState createState() => _VersionGateState(); } -class _TooOldScreen extends StatelessWidget { - const _TooOldScreen(); +class _VersionGateState extends ConsumerState + with WidgetsBindingObserver { + // Polling interval during active foreground use. Paired with the + // controller's 1m staleness gate so concurrent fires (timer + resume) + // dedupe to a single network call. Tight cadence is affordable — + // /healthz is sub-1KB and the call is non-blocking. + static const _pollInterval = Duration(minutes: 1); + Timer? _pollTimer; + @override - Widget build(BuildContext context) { - return const Scaffold( - body: SafeArea( - child: Padding( - padding: EdgeInsets.all(24), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Update required', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.w500), - ), - SizedBox(height: 12), - Text( - 'This client is too old for that Minstrel server. Install the latest build to continue.', - textAlign: TextAlign.center, - ), - ], + void initState() { + super.initState(); + // Read once so the provider's build() runs; the controller + // hydrates from cache + fires a background check when stale. + ref.read(versionCheckProvider); + WidgetsBinding.instance.addObserver(this); + _startPolling(); + } + + @override + void dispose() { + _stopPolling(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + void _startPolling() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(_pollInterval, (_) { + // ignore: unawaited_futures + ref.read(versionCheckProvider.notifier).recheckIfStale(); + }); + } + + void _stopPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + // Re-arm the timer + fire one immediate (staleness-gated) check + // so users who foreground after a long away period get a fresh + // result without waiting for the next tick. + // ignore: unawaited_futures + ref.read(versionCheckProvider.notifier).recheckIfStale(); + _startPolling(); + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + // Stop firing while backgrounded — no need to burn battery on + // health probes the user can't see the result of. + _stopPolling(); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} + +/// Banner that surfaces when the server reports our client is too old. +/// Non-blocking: the rest of the app keeps working (offline-mode-style +/// — locally cached metadata + audio still play). Tap "Check now" to +/// force a re-check after installing a new build. +class VersionTooOldBanner extends ConsumerWidget { + const VersionTooOldBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncResult = ref.watch(versionCheckProvider); + final result = asyncResult.asData?.value; + if (result != VersionResult.tooOld) return const SizedBox.shrink(); + final fs = Theme.of(context).extension()!; + return SafeArea( + bottom: false, + child: Container( + color: fs.error.withValues(alpha: 0.15), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + Icon(Icons.warning_amber_rounded, color: fs.error, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + "This app is older than the server requires. " + "You can keep using cached content; install an update when ready.", + style: TextStyle(color: fs.parchment, fontSize: 13), ), ), - ), + TextButton( + onPressed: () => + ref.read(versionCheckProvider.notifier).recheck(), + style: TextButton.styleFrom( + foregroundColor: fs.parchment, + minimumSize: const Size(0, 36), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + child: const Text('Check now'), + ), + ]), ), ); } diff --git a/flutter_client/pubspec.lock b/flutter_client/pubspec.lock index 95704394..097c6699 100644 --- a/flutter_client/pubspec.lock +++ b/flutter_client/pubspec.lock @@ -121,6 +121,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.6" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -327,7 +351,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" @@ -411,6 +435,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934" + url: "https://pub.dev" + source: hosted + version: "4.1.1" flutter_web_plugins: dependency: transitive description: flutter @@ -664,6 +696,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -688,6 +728,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + palette_generator: + dependency: "direct main" + description: + name: palette_generator + sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed" + url: "https://pub.dev" + source: hosted + version: "0.3.3+7" path: dependency: transitive description: diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml index 68d04ddf..b0d8ee18 100644 --- a/flutter_client/pubspec.yaml +++ b/flutter_client/pubspec.yaml @@ -1,7 +1,7 @@ name: minstrel description: Minstrel mobile client publish_to: 'none' -version: 2026.5.11+1 +version: 2026.5.13+1 environment: sdk: '>=3.5.0 <4.0.0' @@ -28,6 +28,21 @@ dependencies: drift_flutter: ^0.2.0 sqlite3_flutter_libs: ^0.5.24 connectivity_plus: ^6.0.5 + flutter_timezone: ^4.1.1 + palette_generator: ^0.3.3 + # Disk-persistent image cache. Image.network only caches in memory, so + # cover art repainted after a scroll-off or app restart re-fetches from + # the server. cached_network_image stores the bytes under + # path_provider's temp dir keyed by URL, surviving both. Used directly + # by ServerImage (auth-aware path) and as CachedNetworkImageProvider + # for the mini bar (which composes its own Image widget). + cached_network_image: ^3.4.1 + # flutter_cache_manager is the disk-cache layer cached_network_image + # sits on top of. SyncController uses DefaultCacheManager directly to + # pre-warm covers during metadata sync, so a cold-start home grid + # paints from disk on the very first scroll rather than firing a + # network round-trip per tile. + flutter_cache_manager: ^3.4.1 dev_dependencies: flutter_test: diff --git a/flutter_client/test/library/home_screen_test.dart b/flutter_client/test/library/home_screen_test.dart index 3d6cee03..be869cc6 100644 --- a/flutter_client/test/library/home_screen_test.dart +++ b/flutter_client/test/library/home_screen_test.dart @@ -5,28 +5,25 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:minstrel/api/endpoints/playlists.dart'; import 'package:minstrel/library/home_screen.dart'; import 'package:minstrel/library/library_providers.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/artist.dart'; -import 'package:minstrel/models/home_data.dart'; +import 'package:minstrel/models/home_index.dart'; import 'package:minstrel/models/playlist.dart'; import 'package:minstrel/models/system_playlists_status.dart'; import 'package:minstrel/playlists/playlists_provider.dart'; import 'package:minstrel/theme/theme_data.dart'; -const _emptyHome = HomeData( - recentlyAddedAlbums: [], - rediscoverAlbums: [], - rediscoverArtists: [], - mostPlayedTracks: [], - lastPlayedArtists: [], -); +// All tests pump an empty HomeIndex unless they care about populated +// section IDs — per-tile hydration is intentionally not exercised here +// (that requires drift, and the @Tags(['drift']) tier covers it). +// What this suite verifies is the screen's section-level shape: +// placeholders / empty-state copy / section presence given the index. +const _emptyIndex = HomeIndex.empty; void main() { testWidgets('renders 4 placeholder cards in Playlists row when no system playlists exist', (tester) async { await tester.pumpWidget(ProviderScope( overrides: [ - homeProvider.overrideWith((ref) async => _emptyHome), + homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)), playlistsListProvider('all').overrideWith( (ref) => Stream.value(PlaylistsList.empty()), ), @@ -37,16 +34,17 @@ void main() { child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), )); await tester.pumpAndSettle(); - // For-You placeholder + 3 Songs-like placeholders. + // For-You placeholder + Discover placeholder + 3 Songs-like placeholders. expect(find.text('For You'), findsOneWidget); + expect(find.text('Discover'), findsOneWidget); expect(find.text('Songs like…'), findsNWidgets(3)); }); - testWidgets('renders empty-state copy for each section when home payload is empty', + testWidgets('renders empty-state copy for each section when the index is empty', (tester) async { await tester.pumpWidget(ProviderScope( overrides: [ - homeProvider.overrideWith((ref) async => _emptyHome), + homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)), playlistsListProvider('all').overrideWith( (ref) => Stream.value(PlaylistsList.empty()), ), @@ -87,7 +85,7 @@ void main() { ); await tester.pumpWidget(ProviderScope( overrides: [ - homeProvider.overrideWith((ref) async => _emptyHome), + homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)), playlistsListProvider('all').overrideWith( (ref) => Stream.value( const PlaylistsList(owned: [forYou], public: [])), @@ -104,34 +102,8 @@ void main() { expect(find.text('for you'), findsOneWidget); }); - testWidgets('renders home rollups when payload is non-empty', (tester) async { - const home = HomeData( - recentlyAddedAlbums: [ - AlbumRef(id: 'a', title: 'Geogaddi', artistId: 'x', artistName: 'BoC'), - ], - rediscoverAlbums: [], - rediscoverArtists: [ - ArtistRef(id: 'r', name: 'Aphex Twin'), - ], - mostPlayedTracks: [], - lastPlayedArtists: [], - ); - await tester.pumpWidget(ProviderScope( - overrides: [ - homeProvider.overrideWith((ref) async => home), - playlistsListProvider('all').overrideWith( - (ref) => Stream.value(PlaylistsList.empty()), - ), - systemPlaylistsStatusProvider.overrideWith( - (ref) async => SystemPlaylistsStatus.empty(), - ), - ], - child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), - )); - await tester.pumpAndSettle(); - expect(find.text('Recently added'), findsOneWidget); - expect(find.text('Geogaddi'), findsOneWidget); - expect(find.text('Rediscover'), findsOneWidget); - expect(find.text('Aphex Twin'), findsOneWidget); - }); + // Section-with-populated-IDs coverage lives in the drift-tagged + // integration suite (Slice F follow-up) because per-tile providers + // require a real (in-memory) drift DB. The flutter-ci runner skips + // drift tests pending libsqlite3. } diff --git a/flutter_client/test/quarantine/quarantine_provider_test.dart b/flutter_client/test/quarantine/quarantine_provider_test.dart index b146102f..d632ddf3 100644 --- a/flutter_client/test/quarantine/quarantine_provider_test.dart +++ b/flutter_client/test/quarantine/quarantine_provider_test.dart @@ -1,11 +1,23 @@ +@Tags(['drift']) +library; + +import 'package:drift/native.dart' show NativeDatabase; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:minstrel/api/endpoints/quarantine.dart'; -import 'package:minstrel/models/quarantine_mine.dart'; +import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider; +import 'package:minstrel/cache/db.dart'; import 'package:minstrel/models/track.dart'; import 'package:minstrel/quarantine/quarantine_provider.dart'; +// SKIP NOTE: drift's NativeDatabase needs the system libsqlite3.so. +// The flutter-ci runner image doesn't ship it. Same skip as +// sync_controller_test.dart — unblock by adding libsqlite3-dev to the +// CI image, or switch to sqlite3/wasm. On-device runs cover real +// behaviour today. +const _skipDrift = 'libsqlite3 missing on flutter-ci runner; on-device covers'; + class _StubQuarantineApi implements QuarantineApi { _StubQuarantineApi({this.shouldThrow = false}); bool shouldThrow; @@ -25,13 +37,6 @@ class _StubQuarantineApi implements QuarantineApi { } } -class _StubController extends MyQuarantineController { - _StubController(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - const _track = TrackRef( id: 't1', title: 'Roygbiv', @@ -44,44 +49,55 @@ const _track = TrackRef( streamUrl: '', ); -const _existing = QuarantineMineRow( - trackId: 't1', - reason: 'bad_rip', - notes: null, - createdAt: '2026-05-01T00:00:00Z', - trackTitle: 'Roygbiv', - trackDurationMs: 137000, - albumId: 'a1', - albumTitle: 'Geogaddi', - artistId: 'ar1', - artistName: 'Boards of Canada', -); +ProviderContainer _container({required AppDb db, required QuarantineApi api}) { + return ProviderContainer(overrides: [ + appDbProvider.overrideWithValue(db), + quarantineApiProvider.overrideWith((ref) async => api), + ]); +} + +Future _seed(AppDb db) async { + await db.into(db.cachedQuarantineMine).insert( + CachedQuarantineMineCompanion.insert( + trackId: 't1', + reason: 'bad_rip', + createdAt: '2026-05-01T00:00:00Z', + trackTitle: 'Roygbiv', + albumId: 'a1', + albumTitle: 'Geogaddi', + artistId: 'ar1', + artistName: 'Boards of Canada', + ), + ); +} void main() { - test('flag prepends synthetic row and calls server', () async { + test('flag inserts a drift row and calls the server', skip: _skipDrift, + () async { + final db = AppDb(NativeDatabase.memory()); + addTearDown(db.close); final api = _StubQuarantineApi(); - final container = ProviderContainer(overrides: [ - quarantineApiProvider.overrideWith((ref) async => api), - myQuarantineProvider.overrideWith(() => _StubController(const [])), - ]); + final container = _container(db: db, api: api); addTearDown(container.dispose); await container.read(myQuarantineProvider.future); - await container.read(myQuarantineProvider.notifier).flag(_track, 'bad_rip', ''); + await container + .read(myQuarantineProvider.notifier) + .flag(_track, 'bad_rip', ''); expect(api.flagCalls, 1); - final list = container.read(myQuarantineProvider).value!; - expect(list, hasLength(1)); - expect(list.first.trackId, 't1'); - expect(list.first.reason, 'bad_rip'); + final rows = await db.select(db.cachedQuarantineMine).get(); + expect(rows, hasLength(1)); + expect(rows.first.trackId, 't1'); + expect(rows.first.reason, 'bad_rip'); }); - test('flag rolls back on server failure', () async { + test('flag rolls back the drift insert on server failure', skip: _skipDrift, + () async { + final db = AppDb(NativeDatabase.memory()); + addTearDown(db.close); final api = _StubQuarantineApi(shouldThrow: true); - final container = ProviderContainer(overrides: [ - quarantineApiProvider.overrideWith((ref) async => api), - myQuarantineProvider.overrideWith(() => _StubController(const [])), - ]); + final container = _container(db: db, api: api); addTearDown(container.dispose); await container.read(myQuarantineProvider.future); @@ -91,28 +107,33 @@ void main() { .flag(_track, 'bad_rip', ''), throwsException, ); - expect(container.read(myQuarantineProvider).value, isEmpty); + final rows = await db.select(db.cachedQuarantineMine).get(); + expect(rows, isEmpty); }); - test('unflag removes the row and calls server', () async { + test('unflag deletes the drift row and calls the server', skip: _skipDrift, + () async { + final db = AppDb(NativeDatabase.memory()); + addTearDown(db.close); + await _seed(db); final api = _StubQuarantineApi(); - final container = ProviderContainer(overrides: [ - quarantineApiProvider.overrideWith((ref) async => api), - myQuarantineProvider.overrideWith(() => _StubController(const [_existing])), - ]); + final container = _container(db: db, api: api); addTearDown(container.dispose); await container.read(myQuarantineProvider.future); await container.read(myQuarantineProvider.notifier).unflag('t1'); expect(api.unflagCalls, 1); - expect(container.read(myQuarantineProvider).value, isEmpty); + final rows = await db.select(db.cachedQuarantineMine).get(); + expect(rows, isEmpty); }); - test('isHidden reflects current state', () async { - final container = ProviderContainer(overrides: [ - myQuarantineProvider.overrideWith(() => _StubController(const [_existing])), - ]); + test('isHidden reflects drift state', skip: _skipDrift, () async { + final db = AppDb(NativeDatabase.memory()); + addTearDown(db.close); + await _seed(db); + final api = _StubQuarantineApi(); + final container = _container(db: db, api: api); addTearDown(container.dispose); await container.read(myQuarantineProvider.future); diff --git a/go.mod b/go.mod index f0b33727..78ceac27 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,12 @@ go 1.23.0 require ( github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 github.com/go-chi/chi/v5 v5.2.5 + github.com/go-co-op/gocron/v2 v2.21.2 github.com/golang-migrate/migrate/v4 v4.18.2 + github.com/google/uuid v1.6.0 github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa github.com/jackc/pgx/v5 v5.7.4 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.35.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -20,7 +22,9 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect go.uber.org/atomic v1.7.0 // indirect golang.org/x/sync v0.11.0 // indirect diff --git a/go.sum b/go.sum index d305e049..d3b61c32 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY= +github.com/go-co-op/gocron/v2 v2.21.2/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -29,6 +31,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -44,6 +48,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -64,13 +70,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= @@ -81,6 +89,8 @@ go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt3 go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= diff --git a/internal/api/admin_quarantine.go b/internal/api/admin_quarantine.go index a6c2e06d..116f9048 100644 --- a/internal/api/admin_quarantine.go +++ b/internal/api/admin_quarantine.go @@ -111,6 +111,8 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } + // Broadcast: affects every user who'd flagged this track. + h.publishQuarantineEvent("quarantine.resolved", pgtype.UUID{}, id, true) writeJSON(w, http.StatusOK, actionResultView{ ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, @@ -140,6 +142,9 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req } return } + // Broadcast: the track row is gone; every client's library/likes/queue + // invalidates so stale references stop showing. + h.publishQuarantineEvent("quarantine.file_deleted", pgtype.UUID{}, id, true) writeJSON(w, http.StatusOK, actionResultView{ ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, @@ -179,6 +184,8 @@ func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *htt } return } + // Broadcast: same rationale as delete-file — track row gone everywhere. + h.publishQuarantineEvent("quarantine.deleted_via_lidarr", pgtype.UUID{}, id, true) writeJSON(w, http.StatusOK, actionResultView{ ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, diff --git a/internal/api/admin_requests.go b/internal/api/admin_requests.go index 04c6a118..69a61bdb 100644 --- a/internal/api/admin_requests.go +++ b/internal/api/admin_requests.go @@ -125,6 +125,7 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request) return } + h.publishRequestStatusChanged(row) writeJSON(w, http.StatusOK, requestViewFrom(row)) } @@ -164,5 +165,6 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) { return } + h.publishRequestStatusChanged(row) writeJSON(w, http.StatusOK, requestViewFrom(row)) } diff --git a/internal/api/api.go b/internal/api/api.go index e4cde55f..6e039e5d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -14,6 +14,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" + "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" @@ -27,7 +28,7 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, @@ -45,6 +46,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev scheduler: scheduler, dataDir: dataDir, mailer: sender, + eventbus: bus, + playlistScheduler: playlistScheduler, } r.Route("/api", func(api chi.Router) { @@ -62,6 +65,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/me/history", h.handleGetMyHistory) authed.Put("/me/password", h.handleChangePassword) authed.Put("/me/profile", h.handleUpdateMyProfile) + authed.Put("/me/timezone", h.handlePutTimezone) authed.Get("/me/api-token", h.handleGetMyAPIToken) authed.Post("/me/api-token", h.handleRegenerateMyAPIToken) @@ -78,7 +82,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/radio", h.handleRadio) authed.Get("/discover/suggestions", h.handleListSuggestions) authed.Get("/home", h.handleGetHome) + authed.Get("/home/index", h.handleGetHomeIndex) authed.Post("/events", h.handleEvents) + authed.Get("/events/stream", h.handleEventsStream) authed.Post("/likes/tracks/{id}", h.handleLikeTrack) authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack) authed.Post("/likes/albums/{id}", h.handleLikeAlbum) @@ -191,4 +197,6 @@ type handlers struct { scheduler *library.Scheduler dataDir string mailer mailer.Sender + eventbus *eventbus.Bus + playlistScheduler *playlists.Scheduler } diff --git a/internal/api/auth_register.go b/internal/api/auth_register.go index cc98ee2b..849573c1 100644 --- a/internal/api/auth_register.go +++ b/internal/api/auth_register.go @@ -202,6 +202,17 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { map[string]any{"token": usedInviteToken}) } + // Register the new user with the playlist scheduler so their daily + // builds are scheduled without waiting for the hourly reconciliation + // pass. Failure here is logged but doesn't fail registration — the + // next hourly pass will pick them up. + if h.playlistScheduler != nil { + if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil { + h.logger.Warn("api: scheduler refresh after registration", + "user_id", uuidToString(user.ID), "err", err) + } + } + writeJSON(w, http.StatusOK, LoginResponse{ Token: sessionToken, User: UserView{ diff --git a/internal/api/events_publish.go b/internal/api/events_publish.go new file mode 100644 index 00000000..cdf3289a --- /dev/null +++ b/internal/api/events_publish.go @@ -0,0 +1,109 @@ +// Small helpers for posting live events to the SSE bus (#392). Producers +// call these from their handler success paths so the wire shape stays in +// one place. Each helper is a no-op when h.eventbus is nil (older callers +// or tests that don't construct a bus); production always has one. + +package api + +import ( + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" + "github.com/jackc/pgx/v5/pgtype" +) + +// publishLikeEvent broadcasts a track/album/artist like or unlike. The +// event is scoped to the user (so only their own clients invalidate); the +// payload includes the entity_type and entity_id so the dispatcher can +// invalidate the right query keys. +func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType string, liked bool) { + if h.eventbus == nil { + return + } + kind := "track.liked" + if !liked { + kind = "track.unliked" + } + if entityType == "album" { + if liked { + kind = "album.liked" + } else { + kind = "album.unliked" + } + } + if entityType == "artist" { + if liked { + kind = "artist.liked" + } else { + kind = "artist.unliked" + } + } + h.eventbus.Publish(eventbus.Event{ + Kind: kind, + UserID: uuidToString(userID), + Data: map[string]any{ + "entity_type": entityType, + "entity_id": uuidToString(entityID), + }, + }) +} + +// publishQuarantineEvent broadcasts a quarantine action. User-side +// flag/unflag are scoped to the user; admin-side resolve / delete-file / +// delete-via-lidarr are broadcast (empty UserID) because a single admin +// action can affect every user who'd flagged that track — every client +// invalidates and the non-affected ones no-op when the query result +// matches their cache. +func (h *handlers) publishQuarantineEvent(kind string, userID, trackID pgtype.UUID, broadcast bool) { + if h.eventbus == nil { + return + } + scope := "" + if !broadcast { + scope = uuidToString(userID) + } + h.eventbus.Publish(eventbus.Event{ + Kind: kind, + UserID: scope, + Data: map[string]any{ + "track_id": uuidToString(trackID), + }, + }) +} + +// publishPlaylistEvent broadcasts a playlist mutation to the owner so +// their other clients invalidate the affected playlist (and playlist list) +// providers. Public-playlist subscribers are out of scope here — they'd +// need a separate "playlists.public_updated" broadcast, deferred until +// the multi-user case is exercised. +func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.UUID) { + if h.eventbus == nil { + return + } + h.eventbus.Publish(eventbus.Event{ + Kind: kind, + UserID: uuidToString(ownerID), + Data: map[string]any{ + "playlist_id": uuidToString(playlistID), + }, + }) +} + +// publishRequestStatusChanged broadcasts a Lidarr request status flip to +// the request's original requester so their /requests page reflects the +// new state without manual refresh. Admin actors (approve / reject) still +// route to the requester's user_id, not the admin's — the original user +// is who needs to see the update. +func (h *handlers) publishRequestStatusChanged(row dbq.LidarrRequest) { + if h.eventbus == nil { + return + } + h.eventbus.Publish(eventbus.Event{ + Kind: "request.status_changed", + UserID: uuidToString(row.UserID), + Data: map[string]any{ + "request_id": uuidToString(row.ID), + "status": string(row.Status), + "kind": string(row.Kind), + }, + }) +} diff --git a/internal/api/events_stream.go b/internal/api/events_stream.go new file mode 100644 index 00000000..ff58deb0 --- /dev/null +++ b/internal/api/events_stream.go @@ -0,0 +1,135 @@ +// Live event stream — Server-Sent Events at GET /api/events/stream. +// +// Clients (today: Flutter) subscribe to be notified of state changes +// elsewhere in the system (likes, request status flips, quarantine +// resolutions, scan progress, playlist mutations) so static screens +// can invalidate cached data without explicit user action. +// +// Wire-format: standard SSE. Each event has the shape +// +// event: +// data: +// +// where follows "domain.action" naming and +// matches `eventbus.Event`. A heartbeat comment line is emitted every +// 15 seconds to keep idle connections alive through proxies / NATs. +// +// Scoping: events with `user_id` matching the connected user OR with +// empty `user_id` (broadcast) are forwarded. Other users' events are +// dropped silently before reaching the wire. + +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" +) + +// userUUIDString renders a pgtype.UUID in canonical 8-4-4-4-12 form so +// the SSE handler can compare against eventbus.Event.UserID strings. +// Other packages keep their own copies of this same pattern; consolidating +// is out of scope for this slice. +func userUUIDString(u pgtype.UUID) string { + if !u.Valid { + return "" + } + return fmt.Sprintf("%x-%x-%x-%x-%x", + u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16]) +} + +const ( + // sseSubscriberBuffer bounds per-connection event queue depth. Events + // beyond this are dropped (logged at debug); the client resyncs via + // normal /api/* fetches when reconnecting or focusing. + sseSubscriberBuffer = 64 + // sseHeartbeatInterval keeps proxy connections alive. SSE spec allows + // a comment line (starts with ":") as a no-op heartbeat. + sseHeartbeatInterval = 15 * time.Second +) + +func (h *handlers) handleEventsStream(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + // Standard SSE headers. X-Accel-Buffering disables nginx's response + // buffering when Minstrel sits behind it; harmless on direct + // connections. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + // Initial comment so the client sees the connection open immediately + // even if no real event lands for a while. + if _, err := fmt.Fprintf(w, ": connected\n\n"); err != nil { + return + } + flusher.Flush() + + ch, unsub := h.eventbus.Subscribe(sseSubscriberBuffer) + defer unsub() + + heartbeat := time.NewTicker(sseHeartbeatInterval) + defer heartbeat.Stop() + + userIDStr := userUUIDString(user.ID) + ctx := r.Context() + + for { + select { + case <-ctx.Done(): + return + + case <-heartbeat.C: + if _, err := fmt.Fprintf(w, ": heartbeat\n\n"); err != nil { + return + } + flusher.Flush() + + case e, ok := <-ch: + if !ok { + // Bus closed our subscription (rare; only on full shutdown). + return + } + // Scope filter: forward only events scoped to this user or + // broadcast events (empty UserID). + if e.UserID != "" && e.UserID != userIDStr { + continue + } + if err := writeSSEEvent(w, e); err != nil { + return + } + flusher.Flush() + } + } +} + +// writeSSEEvent serializes one bus event in SSE wire format. JSON +// marshal errors collapse the event to an "error" kind so the client +// at least sees something rather than silent drops. +func writeSSEEvent(w http.ResponseWriter, e eventbus.Event) error { + payload, err := json.Marshal(e) + if err != nil { + _, werr := fmt.Fprintf(w, "event: error\ndata: {\"msg\":\"marshal failed\"}\n\n") + return werr + } + _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Kind, payload) + return err +} diff --git a/internal/api/home.go b/internal/api/home.go index c3d2464e..f0f14640 100644 --- a/internal/api/home.go +++ b/internal/api/home.go @@ -49,6 +49,54 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } +// handleGetHomeIndex implements GET /api/home/index. Returns the same +// five sections /api/home computes, but as flat slices of entity IDs +// rather than denormalized objects. Drives the Flutter per-item +// rendering path — the client hydrates each tile against the +// per-entity endpoints, so this response is tiny (a few KB at most) +// and the cold-visit round-trip is correspondingly short. +// +// Shares the recommendation.HomeData computation with /api/home; the +// DB cost is identical. A future optimisation could add ID-only +// query variants, but the JSON savings already shrink the wire +// payload by roughly an order of magnitude on populated libraries. +func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + data, err := recommendation.HomeData(r.Context(), h.pool, user.ID) + if err != nil { + h.logger.Error("api: home index data", "err", err) + writeErr(w, apierror.InternalMsg("failed to load home", err)) + return + } + + out := HomeIndexPayload{ + RecentlyAddedAlbums: make([]string, 0, len(data.RecentlyAddedAlbums)), + RediscoverAlbums: make([]string, 0, len(data.RediscoverAlbums)), + RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)), + MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)), + LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)), + } + for _, row := range data.RecentlyAddedAlbums { + out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID)) + } + for _, row := range data.RediscoverAlbums { + out.RediscoverAlbums = append(out.RediscoverAlbums, uuidToString(row.Album.ID)) + } + for _, row := range data.RediscoverArtists { + out.RediscoverArtists = append(out.RediscoverArtists, uuidToString(row.Artist.ID)) + } + for _, row := range data.MostPlayedTracks { + out.MostPlayedTracks = append(out.MostPlayedTracks, uuidToString(row.Track.ID)) + } + for _, row := range data.LastPlayedArtists { + out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID)) + } + writeJSON(w, http.StatusOK, out) +} + // Compile-time guard that dbq has the row types we need; if the SQL is // regenerated with a different name, this fails fast at build time. var _ = dbq.ListMostPlayedTracksForUserRow{}.Track diff --git a/internal/api/home_test.go b/internal/api/home_test.go index 2f953f9b..123161b5 100644 --- a/internal/api/home_test.go +++ b/internal/api/home_test.go @@ -77,3 +77,67 @@ func TestHome_RecentlyAddedSurfacesAlbums(t *testing.T) { t.Errorf("artist_name = %q, want ArtistOne", got.RecentlyAddedAlbums[0].ArtistName) } } + +func newHomeIndexRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/home/index", h.handleGetHomeIndex) + return r +} + +func doGetHomeIndex(h *handlers, user dbq.User) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/home/index", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newHomeIndexRouter(h).ServeHTTP(w, req) + return w +} + +func TestHomeIndex_EmptyForNewUser(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + w := doGetHomeIndex(h, user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got HomeIndexPayload + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + // Same non-nil slice contract as /api/home so client parsers can + // trust `length == 0` instead of branching on null. + if got.RecentlyAddedAlbums == nil || got.RediscoverAlbums == nil || + got.RediscoverArtists == nil || got.MostPlayedTracks == nil || + got.LastPlayedArtists == nil { + t.Errorf("payload has nil slice: %+v", got) + } + if len(got.RecentlyAddedAlbums) != 0 { + t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) + } +} + +func TestHomeIndex_RecentlyAddedReturnsIDs(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistOne", SortName: "ArtistOne", + }) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Newest", SortTitle: "Newest", ArtistID: artist.ID, + }) + + w := doGetHomeIndex(h, user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got HomeIndexPayload + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got.RecentlyAddedAlbums) != 1 { + t.Fatalf("len = %d, want 1", len(got.RecentlyAddedAlbums)) + } + want := uuidToString(album.ID) + if got.RecentlyAddedAlbums[0] != want { + t.Errorf("id = %q, want %q", got.RecentlyAddedAlbums[0], want) + } +} diff --git a/internal/api/library_test.go b/internal/api/library_test.go index f5dc7f6a..4cc8a3c5 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -14,6 +14,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) @@ -443,7 +444,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil) paths := []string{ "/api/artists", diff --git a/internal/api/likes.go b/internal/api/likes.go index 83621e7f..b36dfcd7 100644 --- a/internal/api/likes.go +++ b/internal/api/likes.go @@ -60,6 +60,7 @@ func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) { _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger) } h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpUpsert) + h.publishLikeEvent(user.ID, id, "track", true) w.WriteHeader(http.StatusNoContent) } @@ -83,6 +84,7 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) { // Don't fail the response — soft-delete is best-effort. } h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpDelete) + h.publishLikeEvent(user.ID, id, "track", false) w.WriteHeader(http.StatusNoContent) } @@ -111,6 +113,7 @@ func (h *handlers) handleLikeAlbum(w http.ResponseWriter, r *http.Request) { return } h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpUpsert) + h.publishLikeEvent(user.ID, id, "album", true) w.WriteHeader(http.StatusNoContent) } @@ -129,6 +132,7 @@ func (h *handlers) handleUnlikeAlbum(w http.ResponseWriter, r *http.Request) { return } h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpDelete) + h.publishLikeEvent(user.ID, id, "album", false) w.WriteHeader(http.StatusNoContent) } @@ -157,6 +161,7 @@ func (h *handlers) handleLikeArtist(w http.ResponseWriter, r *http.Request) { return } h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpUpsert) + h.publishLikeEvent(user.ID, id, "artist", true) w.WriteHeader(http.StatusNoContent) } @@ -175,6 +180,7 @@ func (h *handlers) handleUnlikeArtist(w http.ResponseWriter, r *http.Request) { return } h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpDelete) + h.publishLikeEvent(user.ID, id, "artist", false) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/me_timezone.go b/internal/api/me_timezone.go new file mode 100644 index 00000000..60cfe06e --- /dev/null +++ b/internal/api/me_timezone.go @@ -0,0 +1,70 @@ +// PUT /api/me/timezone — accept the client's IANA timezone and store +// it on the user row. The system-playlist scheduler (#392 Half B) +// reads this column to fire each user's daily 03:00 build in their +// local time. +// +// Scheduler.Refresh(ctx, userID) is invoked in a later commit (Task 3) +// so a timezone change takes effect synchronously rather than waiting +// for the hourly reconciliation pass. + +package api + +import ( + "encoding/json" + "net/http" + "time" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// putTimezoneBody is the JSON body for PUT /api/me/timezone. +type putTimezoneBody struct { + Timezone string `json:"timezone"` +} + +func (h *handlers) handlePutTimezone(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, apierror.Unauthorized("unauthorized", "authentication required")) + return + } + + var body putTimezoneBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body")) + return + } + if body.Timezone == "" { + writeErr(w, apierror.BadRequest("bad_body", "timezone is required")) + return + } + if _, err := time.LoadLocation(body.Timezone); err != nil { + writeErr(w, apierror.BadRequest("invalid_timezone", + "timezone must be a valid IANA name (e.g. America/New_York)")) + return + } + + q := dbq.New(h.pool) + if err := q.UpdateUserTimezone(r.Context(), dbq.UpdateUserTimezoneParams{ + ID: user.ID, + Timezone: body.Timezone, + }); err != nil { + h.logger.Error("api: update timezone", "err", err) + writeErr(w, apierror.InternalMsg("update failed", err)) + return + } + + // Reschedule the user's daily build at their new timezone. Failure + // here doesn't undo the DB write — the hourly reconciliation pass + // would pick up the new tz within an hour at worst. + if h.playlistScheduler != nil { + if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil { + h.logger.Warn("api: scheduler refresh after timezone update", + "user_id", uuidToString(user.ID), "err", err) + } + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/me_timezone_test.go b/internal/api/me_timezone_test.go new file mode 100644 index 00000000..0fae0852 --- /dev/null +++ b/internal/api/me_timezone_test.go @@ -0,0 +1,100 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newMeTimezoneRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Put("/api/me/timezone", h.handlePutTimezone) + return r +} + +func TestPutTimezone_ValidIANA(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "tz1", "irrelevant1", false) + + body := bytes.NewBufferString(`{"timezone":"America/New_York"}`) + req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeTimezoneRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String()) + } + + // Verify the DB row was updated. + got, err := dbq.New(pool).GetUserByID(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if got.Timezone != "America/New_York" { + t.Errorf("timezone = %q, want %q", got.Timezone, "America/New_York") + } + if !got.TimezoneUpdatedAt.Valid { + t.Errorf("expected timezone_updated_at to be set after PUT") + } +} + +func TestPutTimezone_InvalidIANA(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "tz2", "irrelevant1", false) + + body := bytes.NewBufferString(`{"timezone":"Not/A/Real/Zone"}`) + req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeTimezoneRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + + var errResp struct { + Code string `json:"code"` + } + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { + t.Fatalf("decode err response: %v", err) + } + if errResp.Code != "invalid_timezone" { + t.Errorf("error code = %q, want invalid_timezone", errResp.Code) + } +} + +func TestPutTimezone_EmptyTimezone(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "tz3", "irrelevant1", false) + + body := bytes.NewBufferString(`{"timezone":""}`) + req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeTimezoneRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} diff --git a/internal/api/playlists.go b/internal/api/playlists.go index 4d845cee..1a94f5a9 100644 --- a/internal/api/playlists.go +++ b/internal/api/playlists.go @@ -185,6 +185,7 @@ func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) h.writePlaylistErr(w, err, "create") return } + h.publishPlaylistEvent("playlist.created", caller.ID, row.ID) writeJSON(w, http.StatusOK, playlistRowToView(row)) } @@ -240,6 +241,7 @@ func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) h.writePlaylistErr(w, err, "update") return } + h.publishPlaylistEvent("playlist.updated", caller.ID, playlistID) writeJSON(w, http.StatusOK, playlistRowToView(row)) } @@ -258,6 +260,7 @@ func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) h.writePlaylistErr(w, err, "delete") return } + h.publishPlaylistEvent("playlist.deleted", caller.ID, playlistID) w.WriteHeader(http.StatusNoContent) } @@ -294,6 +297,7 @@ func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) { h.writePlaylistErr(w, err, "append tracks") return } + h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID) detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get-after-append") @@ -323,6 +327,7 @@ func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Requ h.writePlaylistErr(w, err, "remove track") return } + h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID) detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get-after-remove") @@ -355,6 +360,7 @@ func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request) h.writePlaylistErr(w, err, "reorder") return } + h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID) detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get-after-reorder") diff --git a/internal/api/quarantine.go b/internal/api/quarantine.go index fe2eacd9..1beec8b2 100644 --- a/internal/api/quarantine.go +++ b/internal/api/quarantine.go @@ -67,6 +67,9 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) { } return } + // Broadcast: the flagging user's other clients invalidate their + // Hidden tab; admins' clients invalidate their quarantine queue. + h.publishQuarantineEvent("quarantine.flagged", user.ID, trackID, true) writeJSON(w, http.StatusCreated, quarantineViewFrom(row)) } @@ -89,6 +92,8 @@ func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) { writeErr(w, apierror.InternalMsg("unflag failed", err)) return } + // Scoped to user: only their other clients need to invalidate. + h.publishQuarantineEvent("quarantine.unflagged", user.ID, id, false) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/requests.go b/internal/api/requests.go index 787ae252..fcd84b95 100644 --- a/internal/api/requests.go +++ b/internal/api/requests.go @@ -175,6 +175,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) { } } + h.publishRequestStatusChanged(row) writeJSON(w, http.StatusCreated, requestViewFrom(row)) } @@ -254,5 +255,6 @@ func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) { return } + h.publishRequestStatusChanged(row) writeJSON(w, http.StatusOK, requestViewFrom(row)) } diff --git a/internal/api/types.go b/internal/api/types.go index 9a0a4e02..d9fc0c06 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -118,6 +118,24 @@ type HomePayload struct { LastPlayedArtists []ArtistRef `json:"last_played_artists"` } +// HomeIndexPayload is the response body of GET /api/home/index — the +// per-item rendering variant of /api/home. Same sections, same caps, +// but each section is a flat slice of entity IDs (UUID strings) +// instead of denormalized objects. Slices are non-nil at JSON encode +// time so empty sections render as `[]`. +// +// Client fetches this, then hydrates each tile against the per-entity +// endpoints (/api/albums/{id}, /api/artists/{id}, /api/tracks/{id}) +// for genuine progressive rendering. The section name implies the +// entity type so no per-entry type tag is needed. +type HomeIndexPayload struct { + RecentlyAddedAlbums []string `json:"recently_added_albums"` + RediscoverAlbums []string `json:"rediscover_albums"` + RediscoverArtists []string `json:"rediscover_artists"` + MostPlayedTracks []string `json:"most_played_tracks"` + LastPlayedArtists []string `json:"last_played_artists"` +} + // HistoryEvent is one play in a user's listening history. Used by // /api/me/history; one event per row from play_events. type HistoryEvent struct { diff --git a/internal/db/dbq/discover.sql.go b/internal/db/dbq/discover.sql.go index 3b9ad897..f4f8266a 100644 --- a/internal/db/dbq/discover.sql.go +++ b/internal/db/dbq/discover.sql.go @@ -12,7 +12,7 @@ import ( ) const listCrossUserLikedTracksForDiscover = `-- name: ListCrossUserLikedTracksForDiscover :many -SELECT DISTINCT t.id, t.album_id, t.artist_id +SELECT t.id, t.album_id, t.artist_id FROM general_likes gl JOIN tracks t ON t.id = gl.track_id WHERE gl.user_id != $1 @@ -30,6 +30,7 @@ SELECT DISTINCT t.id, t.album_id, t.artist_id SELECT 1 FROM lidarr_quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) + GROUP BY t.id, t.album_id, t.artist_id ORDER BY md5(t.id::text || $2::text) LIMIT 60 ` @@ -50,6 +51,11 @@ type ListCrossUserLikedTracksForDiscoverRow struct { // rows (the caller's slot redistribution rolls the deficit into the // other two buckets). // $1 = user_id, $2 = date string for md5 ordering. +// +// GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need +// not appear in the select list — Postgres rejects DISTINCT + ORDER BY +// by-expression at plan time (SQLSTATE 42P10), which previously caused +// the entire Discover build to fail silently. func (q *Queries) ListCrossUserLikedTracksForDiscover(ctx context.Context, arg ListCrossUserLikedTracksForDiscoverParams) ([]ListCrossUserLikedTracksForDiscoverRow, error) { rows, err := q.db.Query(ctx, listCrossUserLikedTracksForDiscover, arg.UserID, arg.Column2) if err != nil { diff --git a/internal/db/dbq/library_changes.sql.go b/internal/db/dbq/library_changes.sql.go index 09256c2a..de326379 100644 --- a/internal/db/dbq/library_changes.sql.go +++ b/internal/db/dbq/library_changes.sql.go @@ -7,8 +7,25 @@ package dbq import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) +const deleteOldLibraryChanges = `-- name: DeleteOldLibraryChanges :execrows +DELETE FROM library_changes WHERE changed_at < $1 +` + +// Removes library_changes rows older than the given cutoff. Run from +// the sync compactor goroutine on a daily tick. Returns the number of +// rows deleted so the worker can log meaningful progress. +func (q *Queries) DeleteOldLibraryChanges(ctx context.Context, changedAt pgtype.Timestamptz) (int64, error) { + result, err := q.db.Exec(ctx, deleteOldLibraryChanges, changedAt) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const getLibraryChangesSince = `-- name: GetLibraryChangesSince :many SELECT id, entity_type, entity_id, op, changed_at FROM library_changes diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 2a50b419..f99894a5 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -522,6 +522,8 @@ type User struct { DisplayName *string AutoApproveRequests bool Email *string + Timezone string + TimezoneUpdatedAt pgtype.Timestamptz } type UserInvite struct { diff --git a/internal/db/dbq/system_playlists.sql.go b/internal/db/dbq/system_playlists.sql.go index 97a8fde8..1c2a3d4f 100644 --- a/internal/db/dbq/system_playlists.sql.go +++ b/internal/db/dbq/system_playlists.sql.go @@ -308,7 +308,7 @@ SELECT p.artist_id, FROM plays p LEFT JOIN liked l ON l.artist_id = p.artist_id ORDER BY score DESC, p.artist_id - LIMIT 3 + LIMIT 5 ` type PickSeedArtistsRow struct { @@ -316,7 +316,10 @@ type PickSeedArtistsRow struct { Score int64 } -// Top-3 most-engaged distinct artists in the user's last 7 days. +// Top-5 most-engaged distinct artist candidates in the user's last 7 +// days. The Go-side picker (pickSeedArtistsForDay) shuffles these +// daily-deterministically and takes the first 3 so the set of +// "Songs like X" mixes rotates day-to-day. // Score = unskipped-play count + 5 if user has liked the artist. func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) { rows, err := q.db.Query(ctx, pickSeedArtists, userID) @@ -408,7 +411,7 @@ func (q *Queries) PickTopPlayedTrackForArtistByUser(ctx context.Context, arg Pic return id, err } -const pickTopPlayedTrackForUser = `-- name: PickTopPlayedTrackForUser :one +const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many SELECT t.id FROM play_events pe JOIN tracks t ON t.id = pe.track_id @@ -417,16 +420,33 @@ SELECT t.id AND pe.was_skipped = false GROUP BY t.id ORDER BY COUNT(*) DESC, t.id - LIMIT 1 + LIMIT 5 ` -// For-You seed selection. Returns the user's most-played non-skipped -// track in the last 7 days; tie-break by track_id for determinism. -func (q *Queries) PickTopPlayedTrackForUser(ctx context.Context, userID pgtype.UUID) (pgtype.UUID, error) { - row := q.db.QueryRow(ctx, pickTopPlayedTrackForUser, userID) - var id pgtype.UUID - err := row.Scan(&id) - return id, err +// For-You candidate seeds. Returns the user's top-5 most-played +// non-skipped tracks in the last 7 days; tie-break by track_id for +// determinism. The Go-side picker (pickForYouSeedForDay) chooses one +// of the returned rows as today's seed via userIDHash so the +// candidate pool rotates day-to-day while staying stable within a +// day. +func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var id pgtype.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const tryClaimSystemPlaylistRun = `-- name: TryClaimSystemPlaylistRun :one diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index 45ceb1ba..3f1b9722 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) { const createUser = `-- name: CreateUser :one INSERT INTO users (username, password_hash, api_token, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type CreateUserParams struct { @@ -87,6 +87,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -94,7 +96,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e const createUserAdmin = `-- name: CreateUserAdmin :one INSERT INTO users (username, password_hash, api_token, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type CreateUserAdminParams struct { @@ -130,6 +132,8 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -141,7 +145,7 @@ VALUES ( (SELECT NOT EXISTS (SELECT 1 FROM users)), $4 ) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type CreateUserFirstAdminRaceParams struct { @@ -185,6 +189,8 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -231,7 +237,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge } const getUserByAPIToken = `-- name: GetUserByAPIToken :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE api_token = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE api_token = $1 ` func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) { @@ -250,12 +256,14 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE lower(email) = lower($1) +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE lower(email) = lower($1) ` // Used by forgot-password lookup. Lowercase comparison both sides @@ -277,12 +285,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE id = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { @@ -301,12 +311,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE username = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE username = $1 ` func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { @@ -325,10 +337,50 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } +const listActiveUsersWithTimezones = `-- name: ListActiveUsersWithTimezones :many +SELECT u.id, u.timezone FROM users u + WHERE EXISTS ( + SELECT 1 FROM play_events pe + WHERE pe.user_id = u.id + AND pe.started_at > now() - INTERVAL '7 days' + ) +` + +type ListActiveUsersWithTimezonesRow struct { + ID pgtype.UUID + Timezone string +} + +// Returns (id, timezone) for every user with a play in the last 7 +// days. The system-playlist scheduler iterates this list at startup +// and during its hourly reconciliation to discover newly-active / +// no-longer-active users. +func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiveUsersWithTimezonesRow, error) { + rows, err := q.db.Query(ctx, listActiveUsersWithTimezones) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListActiveUsersWithTimezonesRow + for rows.Next() { + var i ListActiveUsersWithTimezonesRow + if err := rows.Scan(&i.ID, &i.Timezone); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listUsers = `-- name: ListUsers :many SELECT id, username, display_name, is_admin, auto_approve_requests, created_at FROM users @@ -374,7 +426,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { const regenerateApiToken = `-- name: RegenerateApiToken :one UPDATE users SET api_token = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type RegenerateApiTokenParams struct { @@ -400,6 +452,8 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -477,7 +531,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one UPDATE users SET is_admin = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type UpdateUserAdminParams struct { @@ -503,6 +557,8 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -511,7 +567,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one UPDATE users SET auto_approve_requests = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type UpdateUserAutoApproveParams struct { @@ -536,6 +592,8 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -545,7 +603,7 @@ UPDATE users SET display_name = $2, email = $3 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type UpdateUserProfileParams struct { @@ -572,6 +630,28 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } + +const updateUserTimezone = `-- name: UpdateUserTimezone :exec +UPDATE users + SET timezone = $2, + timezone_updated_at = now() + WHERE id = $1 +` + +type UpdateUserTimezoneParams struct { + ID pgtype.UUID + Timezone string +} + +// Sets the user's IANA timezone and bumps timezone_updated_at. The +// handler validates the timezone string via time.LoadLocation before +// calling this; the DB does not re-validate. +func (q *Queries) UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error { + _, err := q.db.Exec(ctx, updateUserTimezone, arg.ID, arg.Timezone) + return err +} diff --git a/internal/db/migrations/0026_users_timezone.down.sql b/internal/db/migrations/0026_users_timezone.down.sql new file mode 100644 index 00000000..6752f9d1 --- /dev/null +++ b/internal/db/migrations/0026_users_timezone.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users + DROP COLUMN timezone, + DROP COLUMN timezone_updated_at; diff --git a/internal/db/migrations/0026_users_timezone.up.sql b/internal/db/migrations/0026_users_timezone.up.sql new file mode 100644 index 00000000..52edc73d --- /dev/null +++ b/internal/db/migrations/0026_users_timezone.up.sql @@ -0,0 +1,8 @@ +-- 0026_users_timezone.up.sql — per-user timezone for the system-playlist +-- scheduler (#392 Half B). Default 'UTC' so existing rows + brand-new +-- users have a safe value before their first client check-in via +-- PUT /api/me/timezone. The scheduler reads this column to decide +-- which IANA zone to anchor each user's daily 03:00 build to. +ALTER TABLE users + ADD COLUMN timezone text NOT NULL DEFAULT 'UTC', + ADD COLUMN timezone_updated_at timestamptz; diff --git a/internal/db/queries/discover.sql b/internal/db/queries/discover.sql index 8d13392f..3f3762d4 100644 --- a/internal/db/queries/discover.sql +++ b/internal/db/queries/discover.sql @@ -52,7 +52,12 @@ SELECT t.id, t.album_id, t.artist_id -- rows (the caller's slot redistribution rolls the deficit into the -- other two buckets). -- $1 = user_id, $2 = date string for md5 ordering. -SELECT DISTINCT t.id, t.album_id, t.artist_id +-- +-- GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need +-- not appear in the select list — Postgres rejects DISTINCT + ORDER BY +-- by-expression at plan time (SQLSTATE 42P10), which previously caused +-- the entire Discover build to fail silently. +SELECT t.id, t.album_id, t.artist_id FROM general_likes gl JOIN tracks t ON t.id = gl.track_id WHERE gl.user_id != $1 @@ -70,6 +75,7 @@ SELECT DISTINCT t.id, t.album_id, t.artist_id SELECT 1 FROM lidarr_quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) + GROUP BY t.id, t.album_id, t.artist_id ORDER BY md5(t.id::text || $2::text) LIMIT 60; diff --git a/internal/db/queries/library_changes.sql b/internal/db/queries/library_changes.sql index d830c469..463e48e0 100644 --- a/internal/db/queries/library_changes.sql +++ b/internal/db/queries/library_changes.sql @@ -14,3 +14,9 @@ SELECT COALESCE(MAX(id), 0)::BIGINT FROM library_changes; -- name: GetMinLibraryChangeCursor :one SELECT COALESCE(MIN(id), 0)::BIGINT FROM library_changes; + +-- name: DeleteOldLibraryChanges :execrows +-- Removes library_changes rows older than the given cutoff. Run from +-- the sync compactor goroutine on a daily tick. Returns the number of +-- rows deleted so the worker can log meaningful progress. +DELETE FROM library_changes WHERE changed_at < $1; diff --git a/internal/db/queries/system_playlists.sql b/internal/db/queries/system_playlists.sql index fa7443bd..8064dcf9 100644 --- a/internal/db/queries/system_playlists.sql +++ b/internal/db/queries/system_playlists.sql @@ -47,7 +47,10 @@ UPDATE system_playlist_runs UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true; -- name: PickSeedArtists :many --- Top-3 most-engaged distinct artists in the user's last 7 days. +-- Top-5 most-engaged distinct artist candidates in the user's last 7 +-- days. The Go-side picker (pickSeedArtistsForDay) shuffles these +-- daily-deterministically and takes the first 3 so the set of +-- "Songs like X" mixes rotates day-to-day. -- Score = unskipped-play count + 5 if user has liked the artist. WITH plays AS ( SELECT t.artist_id, @@ -67,11 +70,15 @@ SELECT p.artist_id, FROM plays p LEFT JOIN liked l ON l.artist_id = p.artist_id ORDER BY score DESC, p.artist_id - LIMIT 3; + LIMIT 5; --- name: PickTopPlayedTrackForUser :one --- For-You seed selection. Returns the user's most-played non-skipped --- track in the last 7 days; tie-break by track_id for determinism. +-- name: PickTopPlayedTracksForUser :many +-- For-You candidate seeds. Returns the user's top-5 most-played +-- non-skipped tracks in the last 7 days; tie-break by track_id for +-- determinism. The Go-side picker (pickForYouSeedForDay) chooses one +-- of the returned rows as today's seed via userIDHash so the +-- candidate pool rotates day-to-day while staying stable within a +-- day. SELECT t.id FROM play_events pe JOIN tracks t ON t.id = pe.track_id @@ -80,7 +87,7 @@ SELECT t.id AND pe.was_skipped = false GROUP BY t.id ORDER BY COUNT(*) DESC, t.id - LIMIT 1; + LIMIT 5; -- name: PickTopPlayedTrackForArtistByUser :one -- "Songs like X" seed selection. Returns the user's most-played non-skipped diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index c8b81315..800e1b7a 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -142,3 +142,24 @@ RETURNING *; -- to keep the unique index happy and to make the lookup -- case-insensitive. SELECT * FROM users WHERE lower(email) = lower($1); + +-- name: UpdateUserTimezone :exec +-- Sets the user's IANA timezone and bumps timezone_updated_at. The +-- handler validates the timezone string via time.LoadLocation before +-- calling this; the DB does not re-validate. +UPDATE users + SET timezone = $2, + timezone_updated_at = now() + WHERE id = $1; + +-- name: ListActiveUsersWithTimezones :many +-- Returns (id, timezone) for every user with a play in the last 7 +-- days. The system-playlist scheduler iterates this list at startup +-- and during its hourly reconciliation to discover newly-active / +-- no-longer-active users. +SELECT u.id, u.timezone FROM users u + WHERE EXISTS ( + SELECT 1 FROM play_events pe + WHERE pe.user_id = u.id + AND pe.started_at > now() - INTERVAL '7 days' + ); diff --git a/internal/eventbus/eventbus.go b/internal/eventbus/eventbus.go new file mode 100644 index 00000000..1f3504e6 --- /dev/null +++ b/internal/eventbus/eventbus.go @@ -0,0 +1,96 @@ +// Package eventbus is an in-process publish/subscribe bus for real-time +// events the server emits to subscribed clients (today: Flutter via SSE). +// +// Writers (likes handler, requests reconciler, scanner, etc.) call Publish +// to announce a state change. Each connected SSE client holds a +// subscription via Subscribe and forwards events to its HTTP response. +// +// The bus is intentionally simple: +// +// - Per-subscriber bounded channel buffer. If a subscriber's buffer is +// full when an event arrives, the event is dropped FOR THAT SUBSCRIBER +// ONLY — writers never block, and other subscribers are unaffected. +// - No persistence. A client that connects after an event was emitted +// will not see that event. SSE clients are expected to fetch a fresh +// snapshot via the normal /api/* endpoints on (re)connect. +// - No cross-process broadcast. v1 runs as a single Go binary; a future +// multi-instance deployment will need a real broker (NATS / Redis +// pub-sub / etc.) but that's out of scope here. +package eventbus + +import ( + "sync" +) + +// Event is a single broadcast message. Kind names follow a "domain.action" +// convention (e.g. "track.liked", "request.status_changed"). UserID, when +// non-empty, scopes the event to a specific user — subscribers receive +// only events with UserID == their auth user OR with empty UserID (which +// means "broadcast to everyone subscribed"). +type Event struct { + Kind string `json:"kind"` + UserID string `json:"user_id,omitempty"` + Data map[string]any `json:"data"` +} + +// Bus is a fan-out broadcaster. Safe for concurrent use; the zero value is +// not usable — call New(). +type Bus struct { + mu sync.RWMutex + subscribers map[chan Event]struct{} +} + +// New returns a Bus with no subscribers. +func New() *Bus { + return &Bus{subscribers: map[chan Event]struct{}{}} +} + +// Publish fans an event out to every subscriber. Non-blocking per +// subscriber: if a subscriber's buffer is full, the event is dropped for +// that subscriber rather than stalling the writer. +func (b *Bus) Publish(e Event) { + b.mu.RLock() + defer b.mu.RUnlock() + for ch := range b.subscribers { + select { + case ch <- e: + default: + // Subscriber buffer full — drop. The subscriber will resync + // via a normal API fetch when the client decides to. + } + } +} + +// Subscribe registers a new subscriber and returns its receive channel +// plus an unsubscribe func. The buffer argument controls how many events +// can be queued before drops occur; 16–64 is a reasonable range for an +// SSE client that processes events promptly. +// +// Callers MUST invoke the returned unsubscribe func when done (typically +// in a defer), or the subscription leaks. +func (b *Bus) Subscribe(buffer int) (<-chan Event, func()) { + if buffer <= 0 { + buffer = 16 + } + ch := make(chan Event, buffer) + b.mu.Lock() + b.subscribers[ch] = struct{}{} + b.mu.Unlock() + unsub := func() { + b.mu.Lock() + if _, ok := b.subscribers[ch]; ok { + delete(b.subscribers, ch) + close(ch) + } + b.mu.Unlock() + } + return ch, unsub +} + +// SubscriberCount returns the current number of active subscribers. For +// tests + observability; not load-bearing in hot paths. +func (b *Bus) SubscriberCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.subscribers) +} diff --git a/internal/eventbus/eventbus_test.go b/internal/eventbus/eventbus_test.go new file mode 100644 index 00000000..65898b77 --- /dev/null +++ b/internal/eventbus/eventbus_test.go @@ -0,0 +1,150 @@ +package eventbus + +import ( + "sync" + "testing" + "time" +) + +func TestPublish_DeliversToSubscriber(t *testing.T) { + t.Parallel() + b := New() + ch, unsub := b.Subscribe(4) + defer unsub() + + b.Publish(Event{Kind: "test.event", UserID: "u1", Data: map[string]any{"x": 1}}) + + select { + case got := <-ch: + if got.Kind != "test.event" || got.UserID != "u1" { + t.Fatalf("unexpected event: %+v", got) + } + if got.Data["x"] != 1 { + t.Fatalf("payload mismatch: %+v", got.Data) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } +} + +func TestPublish_FanOutToMultipleSubscribers(t *testing.T) { + t.Parallel() + b := New() + ch1, unsub1 := b.Subscribe(4) + ch2, unsub2 := b.Subscribe(4) + defer unsub1() + defer unsub2() + + b.Publish(Event{Kind: "broadcast"}) + + for i, ch := range []<-chan Event{ch1, ch2} { + select { + case got := <-ch: + if got.Kind != "broadcast" { + t.Fatalf("ch%d: kind=%q", i, got.Kind) + } + case <-time.After(time.Second): + t.Fatalf("ch%d: timed out", i) + } + } +} + +func TestPublish_DropsWhenSubscriberBufferFull(t *testing.T) { + t.Parallel() + b := New() + _, unsub := b.Subscribe(2) // tiny buffer + defer unsub() + + // Without draining the channel, push more events than the buffer holds. + // Drops are silent; the assertion is that Publish does not block. + done := make(chan struct{}) + go func() { + for i := 0; i < 100; i++ { + b.Publish(Event{Kind: "flood"}) + } + close(done) + }() + + select { + case <-done: + // Good — Publish returned without blocking on the laggard. + case <-time.After(2 * time.Second): + t.Fatal("Publish blocked on a slow subscriber") + } +} + +func TestUnsubscribe_RemovesFromBusAndClosesChannel(t *testing.T) { + t.Parallel() + b := New() + ch, unsub := b.Subscribe(4) + + if got := b.SubscriberCount(); got != 1 { + t.Fatalf("after Subscribe: SubscriberCount=%d, want 1", got) + } + + unsub() + + if got := b.SubscriberCount(); got != 0 { + t.Fatalf("after unsub: SubscriberCount=%d, want 0", got) + } + + // Channel is closed: receive returns zero value with ok=false. + if _, ok := <-ch; ok { + t.Fatal("expected channel to be closed after unsubscribe") + } +} + +func TestUnsubscribe_Idempotent(t *testing.T) { + t.Parallel() + b := New() + _, unsub := b.Subscribe(4) + unsub() + unsub() // must not panic / double-close + if got := b.SubscriberCount(); got != 0 { + t.Fatalf("SubscriberCount=%d, want 0", got) + } +} + +func TestPublish_ConcurrentWritersAndSubscribers(t *testing.T) { + t.Parallel() + b := New() + + const ( + subs = 8 + perWriter = 50 + writers = 4 + bufferSize = 100 + ) + + var subWG sync.WaitGroup + subWG.Add(subs) + stops := make([]func(), 0, subs) + for i := 0; i < subs; i++ { + ch, unsub := b.Subscribe(bufferSize) + stops = append(stops, unsub) + go func() { + defer subWG.Done() + for range ch { + // Drain until channel closes. + } + }() + } + + var pubWG sync.WaitGroup + pubWG.Add(writers) + for w := 0; w < writers; w++ { + go func() { + defer pubWG.Done() + for i := 0; i < perWriter; i++ { + b.Publish(Event{Kind: "concurrent"}) + } + }() + } + pubWG.Wait() + + // Tear down subscribers — channels close, goroutines exit. + for _, stop := range stops { + stop() + } + subWG.Wait() +} diff --git a/internal/library/eventbus.go b/internal/library/eventbus.go new file mode 100644 index 00000000..6a1c805a --- /dev/null +++ b/internal/library/eventbus.go @@ -0,0 +1,81 @@ +package library + +import ( + "sync" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" +) + +// Package-level bus pointer for scan lifecycle events (#392). Set once +// at startup via SetEventBus; reads are nil-safe so test contexts that +// never call SetEventBus simply skip publishing. +// +// A package-global is the right shape here because: +// +// - There is exactly one bus per process (the same instance powers +// the SSE subscriber endpoint and every publisher across the codebase). +// - Threading it through RunScan + TryStartScan + the Scheduler + +// every test caller would touch ~10 call sites without changing +// behavior at the boundaries that don't care. +// - Other Go stdlib packages use the same pattern (log.SetDefault, +// etc.) for process-wide singletons. +var ( + busMu sync.RWMutex + bus *eventbus.Bus +) + +// SetEventBus wires the live-event bus into the library package so scan +// runs emit scan.run_started / scan.run_finished events. Call once during +// process startup; subsequent calls overwrite the previous bus. +func SetEventBus(b *eventbus.Bus) { + busMu.Lock() + defer busMu.Unlock() + bus = b +} + +// publishScanEvent broadcasts a scan lifecycle event. No-op when no bus +// has been set. Scan events are always broadcast (empty UserID) because +// only admins see the scan status card; non-admin clients' invalidation +// dispatcher will receive the event but won't have any provider keyed on +// scan status, so the receive is harmless. +func publishScanEvent(kind string, runID pgtype.UUID, data map[string]any) { + busMu.RLock() + b := bus + busMu.RUnlock() + if b == nil { + return + } + payload := map[string]any{"run_id": uuidToBusString(runID)} + for k, v := range data { + payload[k] = v + } + b.Publish(eventbus.Event{ + Kind: kind, + UserID: "", // broadcast + Data: payload, + }) +} + +// uuidToBusString renders a pgtype.UUID in canonical 8-4-4-4-12 form. +// Inlined to avoid a back-edge dependency on internal/api or another +// helper-bearing package. +func uuidToBusString(u pgtype.UUID) string { + if !u.Valid { + return "" + } + const hex = "0123456789abcdef" + out := make([]byte, 36) + pos := 0 + for i, b := range u.Bytes { + if i == 4 || i == 6 || i == 8 || i == 10 { + out[pos] = '-' + pos++ + } + out[pos] = hex[b>>4] + out[pos+1] = hex[b&0xf] + pos += 2 + } + return string(out) +} diff --git a/internal/library/scanrun.go b/internal/library/scanrun.go index 97e13a7f..4b8a5db3 100644 --- a/internal/library/scanrun.go +++ b/internal/library/scanrun.go @@ -83,6 +83,7 @@ func RunScan( return pgtype.UUID{}, fmt.Errorf("insert scan_run: %w", err) } logger.Info("scan run started", "id", row.ID) + publishScanEvent("scan.run_started", row.ID, nil) var firstErr error captureErr := func(stage string, stageErr error) { @@ -228,5 +229,8 @@ func RunScan( } logger.Info("scan run complete", "id", row.ID, "error", errMsg) + publishScanEvent("scan.run_finished", row.ID, map[string]any{ + "error_message": errMsg, + }) return row.ID, firstErr } diff --git a/internal/lidarrrequests/reconciler.go b/internal/lidarrrequests/reconciler.go index 9b4ba12e..3546dc1b 100644 --- a/internal/lidarrrequests/reconciler.go +++ b/internal/lidarrrequests/reconciler.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" ) @@ -21,22 +22,65 @@ type Reconciler struct { pool *pgxpool.Pool lidarrCfg *lidarrconfig.Service logger *slog.Logger + bus *eventbus.Bus tick time.Duration batch int32 } // NewReconciler constructs a Reconciler with production defaults: -// 5-minute tick, batch size 50. -func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, logger *slog.Logger) *Reconciler { +// 5-minute tick, batch size 50. Pass nil for bus when no SSE publishing +// is desired (tests, or environments without the event stream enabled). +func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, logger *slog.Logger, bus *eventbus.Bus) *Reconciler { return &Reconciler{ pool: pool, lidarrCfg: cfg, logger: logger, + bus: bus, tick: 5 * time.Minute, batch: 50, } } +// publishCompleted broadcasts a request.status_changed event scoped to +// the original requester. No-op when bus is nil. +func (r *Reconciler) publishCompleted(row dbq.LidarrRequest) { + if r.bus == nil { + return + } + r.bus.Publish(eventbus.Event{ + Kind: "request.status_changed", + UserID: formatUUIDForBus(row.UserID), + Data: map[string]any{ + "request_id": formatUUIDForBus(row.ID), + "status": "completed", + "kind": string(row.Kind), + }, + }) +} + +// formatUUIDForBus renders a pgtype.UUID as the canonical 8-4-4-4-12 hex +// string. Mirrors helpers in internal/api/convert.go and other packages; +// inlined here to avoid a back-edge dependency from lidarrrequests onto +// the api layer. +func formatUUIDForBus(u pgtype.UUID) string { + if !u.Valid { + return "" + } + const hex = "0123456789abcdef" + out := make([]byte, 36) + pos := 0 + for i, b := range u.Bytes { + if i == 4 || i == 6 || i == 8 || i == 10 { + out[pos] = '-' + pos++ + } + out[pos] = hex[b>>4] + out[pos+1] = hex[b&0xf] + pos += 2 + } + return string(out) +} + // Run blocks until ctx is cancelled, ticking every r.tick. Errors from // tickOnce are logged at WARN and never propagated. func (r *Reconciler) Run(ctx context.Context) { @@ -114,13 +158,17 @@ func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row db } return err } - _, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ + completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ ID: row.ID, MatchedArtistID: artistID, MatchedAlbumID: pgtype.UUID{}, MatchedTrackID: pgtype.UUID{}, }) - return err + if err != nil { + return err + } + r.publishCompleted(completed) + return nil } func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error { @@ -138,13 +186,17 @@ func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq } return err } - _, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ + completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ ID: row.ID, MatchedAlbumID: albumID, MatchedArtistID: pgtype.UUID{}, MatchedTrackID: pgtype.UUID{}, }) - return err + if err != nil { + return err + } + r.publishCompleted(completed) + return nil } func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error { @@ -178,13 +230,17 @@ func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq return err } - _, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ + completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ ID: row.ID, MatchedAlbumID: albumID, MatchedTrackID: trackID, MatchedArtistID: pgtype.UUID{}, }) - return err + if err != nil { + return err + } + r.publishCompleted(completed) + return nil } func isNoRows(err error) bool { diff --git a/internal/lidarrrequests/reconciler_integration_test.go b/internal/lidarrrequests/reconciler_integration_test.go index ff13471f..28bbd50a 100644 --- a/internal/lidarrrequests/reconciler_integration_test.go +++ b/internal/lidarrrequests/reconciler_integration_test.go @@ -121,7 +121,7 @@ func TestReconciler_MatchesArtistByMBID(t *testing.T) { Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Boards of Canada", }) - rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } @@ -158,7 +158,7 @@ func TestReconciler_MatchesAlbumByMBID(t *testing.T) { LidarrAlbumMBID: albumMBID, AlbumTitle: "Test Album", }) - rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } @@ -200,7 +200,7 @@ func TestReconciler_MatchesTrackViaAlbumMBID(t *testing.T) { LidarrTrackMBID: trackMBID, TrackTitle: "Track One", }) - rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } @@ -234,7 +234,7 @@ func TestReconciler_NoMatchLeavesPending(t *testing.T) { Kind: "artist", LidarrArtistMBID: "nonexistent-mbid-xyz", ArtistName: "Ghost Artist", }) - rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } @@ -278,7 +278,7 @@ func TestReconciler_AlreadyCompletedRowNotReprocessed(t *testing.T) { t.Fatalf("CompleteLidarrRequest setup: %v", err) } - rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } @@ -313,7 +313,7 @@ func TestReconciler_DisabledIsNoOp(t *testing.T) { Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "No-Op Artist", }) - rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } @@ -345,7 +345,7 @@ func TestReconciler_RunRunsAtLeastOneTick(t *testing.T) { Kind: "artist", LidarrArtistMBID: mbid, ArtistName: "Run Artist", }) - rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil) rec.tick = 25 * time.Millisecond done := make(chan struct{}) @@ -399,7 +399,7 @@ func TestReconciler_TrackKindNoTracksInAlbumYet(t *testing.T) { LidarrTrackMBID: "t-mbid-not-yet", TrackTitle: "Some Track", }) - rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } @@ -428,7 +428,7 @@ func TestReconciler_AlbumKindNoMatchInAlbumsTable(t *testing.T) { LidarrAlbumMBID: "al-not-in-library", AlbumTitle: "Nope Album", }) - rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger()) + rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } diff --git a/internal/playlists/discover.go b/internal/playlists/discover.go index ec97ce04..f86be276 100644 --- a/internal/playlists/discover.go +++ b/internal/playlists/discover.go @@ -2,7 +2,7 @@ package playlists import ( "context" - "fmt" + "log/slog" "github.com/jackc/pgx/v5/pgtype" @@ -34,24 +34,34 @@ type discoverTrack struct { // Returns up to discoverTotalSlots track IDs in the order they should // appear in the playlist (round-robin interleaved across buckets). // Empty slice when the library has no eligible tracks at all. -func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, dateStr string) ([]rankedCandidate, error) { +// +// Individual bucket query failures are logged and treated as empty — +// the redistribution algorithm rolls the deficit into the surviving +// buckets so one broken bucket can't silently kill the whole playlist. +func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) ([]rankedCandidate, error) { dormantRows, err := q.ListDormantArtistTracksForDiscover(ctx, dbq.ListDormantArtistTracksForDiscoverParams{ UserID: userID, Column2: dateStr, }) if err != nil { - return nil, fmt.Errorf("dormant bucket: %w", err) + logger.Warn("discover: dormant bucket failed; continuing with empty pool", + "user_id", uuidStringPL(userID), "err", err) + dormantRows = nil } crossUserRows, err := q.ListCrossUserLikedTracksForDiscover(ctx, dbq.ListCrossUserLikedTracksForDiscoverParams{ UserID: userID, Column2: dateStr, }) if err != nil { - return nil, fmt.Errorf("cross-user bucket: %w", err) + logger.Warn("discover: cross-user bucket failed; continuing with empty pool", + "user_id", uuidStringPL(userID), "err", err) + crossUserRows = nil } randomRows, err := q.ListRandomUnheardTracksForDiscover(ctx, dbq.ListRandomUnheardTracksForDiscoverParams{ UserID: userID, Column2: dateStr, }) if err != nil { - return nil, fmt.Errorf("random bucket: %w", err) + logger.Warn("discover: random bucket failed; continuing with empty pool", + "user_id", uuidStringPL(userID), "err", err) + randomRows = nil } // Adapt sqlc-generated rows into the internal struct, then apply diff --git a/internal/playlists/foryou_test.go b/internal/playlists/foryou_test.go index 578876db..0d69a7ae 100644 --- a/internal/playlists/foryou_test.go +++ b/internal/playlists/foryou_test.go @@ -10,11 +10,20 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) +// testUserID is a stable UUID used to seed the daily-determinism RNGs in +// scoreAndSortCandidates / pickHeadAndTail / pickTopN. Tests pass it to +// every call so the jitter is reproducible across test runs. +var testUserID = pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} + // makeCand constructs a Candidate with distinct track/album/artist IDs // and a SimilarityScore that drives distinguishable scores when passed // through recommendation.Score. Using SimilarityScore alone is sufficient // because systemMixWeights.SimilarityWeight = 1.5 (non-zero), so // different similarity values produce different scores. +// +// Tests below use similarity values with gaps wide enough that the +// systemMixWeights JitterMagnitude (±0.1) cannot perturb the top-of-pool +// ordering, so assertions about head order remain meaningful. func makeCand(trackN, albumN, artistN int, similarity float64) recommendation.Candidate { var tID, aID, arID pgtype.UUID tID.Valid, aID.Valid, arID.Valid = true, true, true @@ -90,7 +99,7 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) { makeCand(2, 11, 101, 0.9), makeCand(3, 12, 102, 0.8), } - got := pickHeadAndTail(in, "2026-05-07", time.Now(), 5, 2) + got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2) if len(got) != 3 { t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got)) } @@ -98,11 +107,12 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) { func TestPickHeadAndTail_ExactlyTotal(t *testing.T) { // Pool == headN+tailN: no tail to sample from, returns all. + // Wide score gaps (1.0 per step) keep jitter from perturbing order. in := make([]recommendation.Candidate, 0, 7) for i := 0; i < 7; i++ { - in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)/10.0)) + in = append(in, makeCand(i+1, i+1, i+1, float64(7-i))) } - got := pickHeadAndTail(in, "2026-05-07", time.Now(), 5, 2) + got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2) if len(got) != 7 { t.Errorf("len = %d, want 7 (pool == total)", len(got)) } @@ -111,11 +121,12 @@ func TestPickHeadAndTail_ExactlyTotal(t *testing.T) { func TestPickHeadAndTail_HeadAndTailSplit(t *testing.T) { // Pool of 100 distinct (album, artist) pairs; no caps trim. // With headN=20, tailN=5, expect 25 entries total. + // Wide score gaps (1.0 per step) keep jitter from perturbing order. in := make([]recommendation.Candidate, 0, 100) for i := 0; i < 100; i++ { - in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)/100.0)) + in = append(in, makeCand(i+1, i+1, i+1, float64(100-i))) } - got := pickHeadAndTail(in, "2026-05-07", time.Now(), 20, 5) + got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5) if len(got) != 25 { t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got)) } @@ -125,11 +136,11 @@ func TestPickHeadAndTail_Determinism(t *testing.T) { // Same inputs, same dateStr → identical output both times. in := make([]recommendation.Candidate, 0, 100) for i := 0; i < 100; i++ { - in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)/100.0)) + in = append(in, makeCand(i+1, i+1, i+1, float64(100-i))) } now := time.Now() - got1 := pickHeadAndTail(in, "2026-05-07", now, 20, 5) - got2 := pickHeadAndTail(in, "2026-05-07", now, 20, 5) + got1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5) + got2 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5) if len(got1) != len(got2) { t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2)) } @@ -147,11 +158,11 @@ func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) { // because tieBreakHash incorporates the date. in := make([]recommendation.Candidate, 0, 100) for i := 0; i < 100; i++ { - in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)/100.0)) + in = append(in, makeCand(i+1, i+1, i+1, float64(100-i))) } now := time.Now() - day1 := pickHeadAndTail(in, "2026-05-07", now, 20, 5) - day2 := pickHeadAndTail(in, "2026-05-08", now, 20, 5) + day1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5) + day2 := pickHeadAndTail(in, testUserID, "2026-05-08", now, 20, 5) if len(day1) != 25 || len(day2) != 25 { t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2)) } @@ -190,10 +201,11 @@ func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) { in := make([]recommendation.Candidate, 0, 50) for i := 0; i < 50; i++ { // trackN = i+1, score descends: track 1 scores highest. - sim := float64(50-i) / 50.0 + // Wide score gaps (1.0 per step) keep jitter from perturbing order. + sim := float64(50 - i) in = append(in, makeCand(i+1, i+1, i+1, sim)) } - got := pickHeadAndTail(in, "2026-05-07", time.Now(), 5, 3) + got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 3) if len(got) != 8 { t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got)) } @@ -226,7 +238,7 @@ func TestPickTopN_DiversityCap(t *testing.T) { makeCand(4, 13, 100, 0.7), // 4th by artist → dropped by cap makeCand(5, 14, 100, 0.6), // 5th by artist → dropped by cap } - got := pickTopN(in, "2026-05-07", time.Now(), 25) + got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25) if len(got) != 3 { t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got)) } diff --git a/internal/playlists/scheduler.go b/internal/playlists/scheduler.go new file mode 100644 index 00000000..96376584 --- /dev/null +++ b/internal/playlists/scheduler.go @@ -0,0 +1,255 @@ +// Per-user system-playlist scheduler (#392 Half B). Replaces the +// 24h-anchored cron loop in system_cron.go (deleted in the same arc). +// +// Each active user gets one gocron daily job at 03:00 in their stored +// timezone. The scheduler reconciles its in-memory job set with the +// "active users" query once per hour so newly-active / no-longer- +// active users are picked up without restarts. New-user registration +// and PUT /api/me/timezone both call Refresh() so changes take effect +// synchronously without waiting for the hourly pass. +// +// Scheduler ownership: cmd/minstrel/main.go constructs one instance +// at startup, calls Start, and stops it on graceful shutdown. The +// server.Server struct holds a pointer so the API layer can call +// Refresh from handlers. + +package playlists + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// dailyRebuildHour is the local-time hour at which each user's system +// playlists rebuild. 03:00 is well past most "today" listening and +// before morning use; matches the operator framing in the design doc. +const dailyRebuildHour = 3 + +// reconciliationInterval drives the hourly pass that brings the +// in-memory job set in sync with the live "active users" query. +const reconciliationInterval = time.Hour + +// Scheduler owns one gocron daily job per active user. Refresh and +// the hourly reconciliation pass mutate s.jobs under s.mu. +type Scheduler struct { + gocron gocron.Scheduler + pool *pgxpool.Pool + logger *slog.Logger + dataDir string + + mu sync.Mutex + jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id +} + +// NewScheduler builds an idle scheduler. Caller must invoke Start +// before any builds fire. +func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Scheduler, error) { + g, err := gocron.NewScheduler() + if err != nil { + return nil, fmt.Errorf("init gocron: %w", err) + } + return &Scheduler{ + gocron: g, + pool: pool, + logger: logger, + dataDir: dataDir, + jobs: map[pgtype.UUID]uuid.UUID{}, + }, nil +} + +// Start clears any stale in_flight rows from a previous crash, +// registers a daily-3am job for every active user at their stored +// timezone, fires a one-shot runOnce to catch up missed schedules, +// and starts the gocron loop. +// +// ctx scopes BuildSystemPlaylists calls fired by the jobs. Cancel +// the parent context to drain in-progress builds on shutdown. +func (s *Scheduler) Start(ctx context.Context) error { + q := dbq.New(s.pool) + if err := q.ClearStaleSystemPlaylistInFlight(ctx); err != nil { + s.logger.Warn("scheduler: clear stale in_flight failed", "err", err) + // Non-fatal — TryClaimSystemPlaylistRun will block any + // concurrent build attempts; stale rows just stay stuck. + } + + users, err := q.ListActiveUsersWithTimezones(ctx) + if err != nil { + return fmt.Errorf("list active users: %w", err) + } + + s.mu.Lock() + for _, u := range users { + if err := s.registerLocked(ctx, u.ID, u.Timezone); err != nil { + s.logger.Warn("scheduler: register at startup failed", + "user_id", uuidStringPL(u.ID), "err", err) + } + } + s.mu.Unlock() + + // One-shot startup catch-up: build for every active user right + // now, regardless of their scheduled time. Mirrors the existing + // system_cron.go startup runOnce so users who missed yesterday's + // schedule (process down) get caught up. + go s.runStartupCatchUp(ctx, users) + + // Hourly reconciliation pass. + if _, err := s.gocron.NewJob( + gocron.DurationJob(reconciliationInterval), + gocron.NewTask(func() { + s.reconcile(ctx) + }), + ); err != nil { + return fmt.Errorf("register reconciliation job: %w", err) + } + + s.gocron.Start() + s.logger.Info("scheduler: started", "users", len(users)) + return nil +} + +// Refresh removes the user's existing job (if any) and registers a +// fresh one at their current timezone. Safe to call from any +// goroutine. Returns an error only on DB lookup failure; gocron-side +// errors are logged but not propagated since they don't represent +// recoverable conditions for the caller. +func (s *Scheduler) Refresh(ctx context.Context, userID pgtype.UUID) error { + q := dbq.New(s.pool) + user, err := q.GetUserByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + s.mu.Lock() + defer s.mu.Unlock() + return s.registerLocked(ctx, userID, user.Timezone) +} + +// registerLocked replaces the user's job entry. Caller must hold s.mu. +// +// gocron v2 doesn't expose a per-job location option — WithLocation is +// a SchedulerOption that affects every job. To get per-user timezones +// we use a cron expression with the CRON_TZ= prefix, which the +// underlying robfig/cron parser honors. The expression "CRON_TZ=$tz +// 0 3 * * *" fires at 03:00 in the named zone every day. +func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz string) error { + if existing, ok := s.jobs[userID]; ok { + if err := s.gocron.RemoveJob(existing); err != nil { + s.logger.Warn("scheduler: remove existing job failed", + "user_id", uuidStringPL(userID), "err", err) + } + delete(s.jobs, userID) + } + // Fall back to UTC if the stored tz is unparseable so a corrupted + // DB row doesn't error out registration. + resolvedTZ := "UTC" + if _, err := validateTimezone(tz); err == nil { + resolvedTZ = tz + } + cronExpr := fmt.Sprintf("CRON_TZ=%s 0 %d * * *", resolvedTZ, dailyRebuildHour) + job, err := s.gocron.NewJob( + gocron.CronJob(cronExpr, false /* withSeconds */), + gocron.NewTask(func() { + if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, time.Now(), s.dataDir); err != nil { + s.logger.Warn("scheduler: build failed", + "user_id", uuidStringPL(userID), "err", err) + } + }), + ) + if err != nil { + return fmt.Errorf("register daily job: %w", err) + } + s.jobs[userID] = job.ID() + return nil +} + +// reconcile syncs the in-memory job set with the live active-users +// query. Newly-active users get jobs; no-longer-active users have +// their jobs removed. Runs hourly. +func (s *Scheduler) reconcile(ctx context.Context) { + q := dbq.New(s.pool) + users, err := q.ListActiveUsersWithTimezones(ctx) + if err != nil { + s.logger.Warn("scheduler: reconcile list failed", "err", err) + return + } + + active := make(map[pgtype.UUID]string, len(users)) + for _, u := range users { + active[u.ID] = u.Timezone + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Add users newly active. + for id, tz := range active { + if _, ok := s.jobs[id]; !ok { + if err := s.registerLocked(ctx, id, tz); err != nil { + s.logger.Warn("scheduler: reconcile add failed", + "user_id", uuidStringPL(id), "err", err) + } + } + } + + // Remove users no longer active. + for id := range s.jobs { + if _, ok := active[id]; !ok { + if jobID, exists := s.jobs[id]; exists { + if err := s.gocron.RemoveJob(jobID); err != nil { + s.logger.Warn("scheduler: reconcile remove failed", + "user_id", uuidStringPL(id), "err", err) + } + delete(s.jobs, id) + } + } + } +} + +// runStartupCatchUp fires BuildSystemPlaylists once for every active +// user at startup, regardless of scheduled time. Matches the +// behavior of the previous system_cron.go startup runOnce so users +// whose scheduled fire was missed during downtime get caught up. +func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) { + for _, u := range users { + if err := BuildSystemPlaylists(ctx, s.pool, s.logger, u.ID, time.Now(), s.dataDir); err != nil { + s.logger.Warn("scheduler: startup catch-up build failed", + "user_id", uuidStringPL(u.ID), "err", err) + } + } +} + +// Stop drains gocron and stops the scheduler loop. +func (s *Scheduler) Stop() { + if err := s.gocron.Shutdown(); err != nil { + s.logger.Warn("scheduler: shutdown failed", "err", err) + } +} + +// validateTimezone returns the parsed Location for tz, or an error. +// Callers should use this when accepting input from clients. +func validateTimezone(tz string) (*time.Location, error) { + if tz == "" { + return nil, fmt.Errorf("timezone is empty") + } + return time.LoadLocation(tz) +} + +// validateTimezoneOrUTC returns the parsed Location for tz, or +// time.UTC if parsing fails. Used internally by the scheduler so +// corrupted DB values don't crash the process. +func validateTimezoneOrUTC(tz string) *time.Location { + loc, err := validateTimezone(tz) + if err != nil { + return time.UTC + } + return loc +} diff --git a/internal/playlists/scheduler_test.go b/internal/playlists/scheduler_test.go new file mode 100644 index 00000000..9ec232b3 --- /dev/null +++ b/internal/playlists/scheduler_test.go @@ -0,0 +1,51 @@ +package playlists + +import ( + "testing" +) + +func TestValidateTimezone_Valid(t *testing.T) { + cases := []string{"UTC", "America/New_York", "Europe/London", "Asia/Tokyo"} + for _, tz := range cases { + t.Run(tz, func(t *testing.T) { + loc, err := validateTimezone(tz) + if err != nil { + t.Fatalf("validateTimezone(%q): %v", tz, err) + } + if loc == nil { + t.Fatalf("validateTimezone(%q): nil location", tz) + } + }) + } +} + +func TestValidateTimezone_Invalid(t *testing.T) { + cases := []string{"", "Not/A/Zone", "GMT+5", "EST5EDT-not-iana"} + for _, tz := range cases { + t.Run(tz, func(t *testing.T) { + _, err := validateTimezone(tz) + if err == nil { + t.Fatalf("validateTimezone(%q): expected error, got nil", tz) + } + }) + } +} + +func TestValidateTimezoneOrUTC_FallbackToUTC(t *testing.T) { + // validateTimezoneOrUTC returns UTC for any unparseable input. + // Used by the scheduler when reading stored timezones — corrupted + // DB values should not crash; they should fall back to UTC. + loc := validateTimezoneOrUTC("Not/A/Zone") + if loc.String() != "UTC" { + t.Errorf("expected UTC fallback for invalid input, got %s", loc) + } + + // Valid input returns the named location, not UTC. + loc = validateTimezoneOrUTC("America/New_York") + if loc.String() == "UTC" { + t.Errorf("valid input incorrectly fell back to UTC") + } + if loc.String() != "America/New_York" { + t.Errorf("expected America/New_York, got %s", loc) + } +} diff --git a/internal/playlists/seed_selection_test.go b/internal/playlists/seed_selection_test.go index 6dc28136..c3e5ec43 100644 --- a/internal/playlists/seed_selection_test.go +++ b/internal/playlists/seed_selection_test.go @@ -45,3 +45,191 @@ func TestPickSeedArtistsFromRows_Empty(t *testing.T) { t.Errorf("empty input should yield empty output; got %v", got) } } + +// userIDHash is the per-user, per-day hash that drives the daily- +// determinism RNGs. Same family as tieBreakHash, just keyed on user +// ID instead of track ID. + +func TestUserIDHash_Deterministic(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} + a := userIDHash(u, "2026-05-04") + b := userIDHash(u, "2026-05-04") + if a != b { + t.Fatalf("same inputs gave different hashes: %d vs %d", a, b) + } +} + +func TestUserIDHash_DifferentDateChangesHash(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} + a := userIDHash(u, "2026-05-04") + b := userIDHash(u, "2026-05-05") + if a == b { + t.Errorf("different dates should change hash; both = %d", a) + } +} + +func TestUserIDHash_DifferentUserChangesHash(t *testing.T) { + a := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} + b := pgtype.UUID{Bytes: [16]byte{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1}, Valid: true} + if userIDHash(a, "2026-05-04") == userIDHash(b, "2026-05-04") { + t.Errorf("different users should change hash") + } +} + +// pickForYouSeedForDay rotates the chosen For-You seed across the +// user's top-played candidates using userIDHash. Verifies the picker +// is deterministic within a day, varies across days, and degrades +// gracefully when fewer than 5 candidates exist. + +func TestPickForYouSeedForDay_DeterministicWithinDay(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + seeds := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + {Bytes: [16]byte{30}, Valid: true}, + {Bytes: [16]byte{40}, Valid: true}, + {Bytes: [16]byte{50}, Valid: true}, + } + a := pickForYouSeedForDay(seeds, u, "2026-05-04") + b := pickForYouSeedForDay(seeds, u, "2026-05-04") + if a != b { + t.Fatalf("same day should pick same seed; got %v then %v", a, b) + } +} + +func TestPickForYouSeedForDay_VariesAcrossDays(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + seeds := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + {Bytes: [16]byte{30}, Valid: true}, + {Bytes: [16]byte{40}, Valid: true}, + {Bytes: [16]byte{50}, Valid: true}, + } + picks := map[[16]byte]bool{} + for i := 1; i <= 30; i++ { + date := "2026-05-" + twoDigits(i) + picks[pickForYouSeedForDay(seeds, u, date).Bytes] = true + } + if len(picks) < 2 { + t.Errorf("expected >=2 distinct seeds across 30 days; got %d", len(picks)) + } +} + +func TestPickForYouSeedForDay_SingleCandidate(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true} + got := pickForYouSeedForDay([]pgtype.UUID{only}, u, "2026-05-04") + if got != only { + t.Errorf("single-seed pool should return that seed; got %v", got) + } +} + +func TestPickForYouSeedForDay_EmptyPool(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + got := pickForYouSeedForDay(nil, u, "2026-05-04") + if got.Valid { + t.Errorf("empty pool should return zero UUID; got %v", got) + } +} + +// pickSeedArtistsForDay takes the user's top-5 candidate artists and +// returns 3 of them via daily-deterministic shuffle. Verifies the +// picker is deterministic within a day, varies across days, and +// degrades gracefully when fewer than 3 or 5 candidates exist. + +func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + pool := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + {Bytes: [16]byte{30}, Valid: true}, + {Bytes: [16]byte{40}, Valid: true}, + {Bytes: [16]byte{50}, Valid: true}, + } + a := pickSeedArtistsForDay(pool, u, "2026-05-04") + b := pickSeedArtistsForDay(pool, u, "2026-05-04") + if len(a) != 3 || len(b) != 3 { + t.Fatalf("expected 3 seeds; got %d / %d", len(a), len(b)) + } + for i := range a { + if a[i] != b[i] { + t.Fatalf("same day should produce same order; differs at i=%d", i) + } + } +} + +func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + pool := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + {Bytes: [16]byte{30}, Valid: true}, + {Bytes: [16]byte{40}, Valid: true}, + {Bytes: [16]byte{50}, Valid: true}, + } + // Collect the trio (as a sorted byte tuple) across many dates. + // With C(5,3) = 10 possible trios, 30 dates should yield >=2 distinct sets. + seen := map[[3]byte]bool{} + for i := 1; i <= 30; i++ { + got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i)) + if len(got) != 3 { + t.Fatalf("expected 3 seeds; got %d", len(got)) + } + // Sort the three byte values for set-equivalence comparison. + v := [3]byte{got[0].Bytes[0], got[1].Bytes[0], got[2].Bytes[0]} + if v[0] > v[1] { + v[0], v[1] = v[1], v[0] + } + if v[1] > v[2] { + v[1], v[2] = v[2], v[1] + } + if v[0] > v[1] { + v[0], v[1] = v[1], v[0] + } + seen[v] = true + } + if len(seen) < 2 { + t.Errorf("expected >=2 distinct seed trios across 30 days; got %d", len(seen)) + } +} + +func TestPickSeedArtistsForDay_FewerThanFive(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + pool := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + {Bytes: [16]byte{30}, Valid: true}, + } + got := pickSeedArtistsForDay(pool, u, "2026-05-04") + if len(got) != 3 { + t.Errorf("with 3 candidates, expected all 3 returned; got %d", len(got)) + } +} + +func TestPickSeedArtistsForDay_FewerThanThree(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + pool := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + } + got := pickSeedArtistsForDay(pool, u, "2026-05-04") + if len(got) != 2 { + t.Errorf("with 2 candidates, expected 2 returned; got %d", len(got)) + } +} + +func TestPickSeedArtistsForDay_Empty(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + got := pickSeedArtistsForDay(nil, u, "2026-05-04") + if len(got) != 0 { + t.Errorf("empty pool should return empty; got %d", len(got)) + } +} + +func twoDigits(n int) string { + if n < 10 { + return "0" + string(rune('0'+n)) + } + return string(rune('0'+n/10)) + string(rune('0'+n%10)) +} diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 4d76fd73..63c1f800 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "log/slog" + "math/rand" "sort" "time" @@ -66,6 +67,63 @@ func tieBreakHash(trackID pgtype.UUID, dateStr string) uint64 { return binary.BigEndian.Uint64(sum[:8]) } +// userIDHash returns a deterministic 64-bit hash of (user_id, dateStr). +// Same family as tieBreakHash, just keyed on user_id instead of +// track_id. Used to drive daily-deterministic seed picks and to seed +// the per-build scoring RNG: same (user, day) → same hash; different +// days → different hashes. +func userIDHash(userID pgtype.UUID, dateStr string) uint64 { + h := sha256.New() + if userID.Valid { + _, _ = h.Write(userID.Bytes[:]) + } + _, _ = h.Write([]byte(dateStr)) + sum := h.Sum(nil) + return binary.BigEndian.Uint64(sum[:8]) +} + +// pickForYouSeedForDay picks one of the user's top-played candidate +// tracks as today's For-You seed. With 5 candidates and SHA-256-based +// rotation, each candidate gets picked roughly 1 day in 5; the chosen +// seed drives the similarity candidate pool, so the resulting mix is +// substantially different across days for a user with diverse top-N +// plays. +// +// Degrades gracefully: 1 candidate → returns it; 0 → returns zero UUID +// (caller treats as "no seed available" and skips For-You). +func pickForYouSeedForDay(seeds []pgtype.UUID, userID pgtype.UUID, dateStr string) pgtype.UUID { + if len(seeds) == 0 { + return pgtype.UUID{} + } + idx := int(userIDHash(userID, dateStr) % uint64(len(seeds))) + return seeds[idx] +} + +// pickSeedArtistsForDay takes a candidate pool of up to N artists and +// returns up to 3 of them, daily-deterministically shuffled. Each day +// gets a different ordering / selection, but within-day stability is +// preserved (same inputs always produce the same output). +// +// Uses Fisher-Yates over a copy of the input, seeded by userIDHash. +// Degrades gracefully: <3 candidates returns whatever is available in +// shuffled order. +func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID { + if len(pool) == 0 { + return nil + } + shuffled := make([]pgtype.UUID, len(pool)) + copy(shuffled, pool) + rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) + rng.Shuffle(len(shuffled), func(i, j int) { + shuffled[i], shuffled[j] = shuffled[j], shuffled[i] + }) + n := 3 + if len(shuffled) < n { + n = len(shuffled) + } + return shuffled[:n] +} + // rankedCandidate is a (track_id, score) pair used during in-memory // sorting before insert into playlist_tracks. T5 fills these from // recommendation.Candidate scores. @@ -77,44 +135,46 @@ type rankedCandidate struct { const systemMixLength = 25 // systemMixWeights are the fixed scoring weights used by the cron worker. -// JitterMagnitude is 0 because daily determinism comes from tieBreakHash; -// any randomness would defeat the within-day stability invariant. +// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded +// RNG (see scoreAndSortCandidates) — same (user, day) produces same +// scores within a day, but near-tied candidates reshuffle across days +// so the playlist doesn't feel frozen. var systemMixWeights = recommendation.ScoringWeights{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, SkipPenalty: 2.0, - JitterMagnitude: 0.0, + JitterMagnitude: 0.1, ContextWeight: 0.5, SimilarityWeight: 1.5, } -// noopRNG returns 0 for any call. Combined with JitterMagnitude=0 it makes -// recommendation.Score fully deterministic. -func noopRNG() float64 { return 0 } - // forYouHeadN is the number of top-scored tracks that anchor the For-You -// playlist. forYouTailN is the number of diversity picks sampled from the -// tail of the score-sorted pool (positions 2*forYouHeadN onward), injected -// after the head to give users a daily-deterministic surprise without -// compromising quality. Songs-like-X keeps simple pickTopN (the seed-artist -// context already frames the "you'll like this" promise). +// playlist; forYouTailN is the number sampled from positions 2*headN +// onward, daily-deterministic via tieBreakHash. The 12 + 13 split +// (~50% from the tail) gives the user a substantially different mix +// day-to-day while preserving a recognizable anchor of strong +// similarity matches. Songs-like-X keeps simple pickTopN (the +// seed-artist context already frames the "you'll like this" promise). const ( - forYouHeadN = 20 - forYouTailN = 5 + forYouHeadN = 12 + forYouTailN = 13 ) // scoreAndSortCandidates scores every candidate with recommendation.Score // and returns a new slice sorted by score DESC (ties broken by -// tieBreakHash). Pure — no truncation, no cap. -func scoreAndSortCandidates(cands []recommendation.Candidate, dateStr string, now time.Time) []recommendation.Candidate { +// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is +// deterministic per (user, day) but rotates across days. Pure — no +// truncation, no cap. +func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time) []recommendation.Candidate { + rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) type scored struct { c recommendation.Candidate score float64 } pairs := make([]scored, len(cands)) for i, c := range cands { - pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG)} + pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)} } sort.SliceStable(pairs, func(i, j int) bool { if pairs[i].score != pairs[j].score { @@ -205,14 +265,17 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. } }() - // 1. For-You: synth seed from top recently-played track, pull candidates. - forYouSeed, err := q.PickTopPlayedTrackForUser(ctx, userID) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - buildErr = fmt.Errorf("pick for-you seed: %w", err) + // 1. For-You: pick today's seed from the user's top-5 played tracks. + // Seed rotates daily via userIDHash so the candidate pool shifts + // across days while staying stable within a day. + forYouSeeds, err := q.PickTopPlayedTracksForUser(ctx, userID) + if err != nil { + buildErr = fmt.Errorf("pick for-you seed candidates: %w", err) return buildErr } + forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr) var forYouTracks []rankedCandidate - if err == nil && forYouSeed.Valid { + if forYouSeed.Valid { zeroVec := recommendation.SessionVector{Seed: true} cands, cerr := recommendation.LoadCandidatesFromSimilarity( ctx, q, userID, forYouSeed, @@ -227,14 +290,16 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. // surprise. Songs-like-X keeps pickTopN (top-25 with caps) since // the seed-artist context already provides the "you'll like this" // framing. - forYouTracks = pickHeadAndTail(cands, dateStr, now, forYouHeadN, forYouTailN) + forYouTracks = pickHeadAndTail(cands, userID, dateStr, now, forYouHeadN, forYouTailN) } else { logger.Warn("system playlist: for-you candidates load failed; skipping", "user_id", uuidStringPL(userID), "err", cerr) } } - // 2. Seed artists for "Songs like {X}". + // 2. Seed artists for "Songs like {X}". SQL returns up to 5 candidates + // in score order; pickSeedArtistsForDay shuffles them daily- + // deterministically and takes 3 so the set of mixes rotates day-to-day. seedRows, err := q.PickSeedArtists(ctx, userID) if err != nil { buildErr = fmt.Errorf("pick seed artists: %w", err) @@ -246,7 +311,8 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. ArtistID: r.ArtistID, Score: r.Score, }) } - seeds := pickSeedArtistsFromRows(seedRowsLocal) + seedPool := pickSeedArtistsFromRows(seedRowsLocal) + seeds := pickSeedArtistsForDay(seedPool, userID, dateStr) type seedMix struct { ArtistID pgtype.UUID @@ -285,7 +351,7 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. filtered = append(filtered, c) } } - tracks := pickTopN(filtered, dateStr, now, systemMixLength) + tracks := pickTopN(filtered, userID, dateStr, now, systemMixLength) seedMixes = append(seedMixes, seedMix{ ArtistID: artistID, ArtistName: artistRow.Name, @@ -294,7 +360,10 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. } // 4. Discover: surface unheard tracks, biased toward dormant artists. - discoverTracks, derr := buildDiscoverCandidates(ctx, q, userID, dateStr) + // Individual bucket failures are logged inside buildDiscoverCandidates + // and redistribute as deficit; the function only returns an error for + // unrecoverable conditions, so a remaining derr here is genuine. + discoverTracks, derr := buildDiscoverCandidates(ctx, q, logger, userID, dateStr) if derr != nil { logger.Warn("system playlist: discover candidates load failed; skipping", "user_id", uuidStringPL(userID), "err", derr) @@ -382,17 +451,18 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. // per-artist (<=3) diversity caps matching Discover's behavior, then // truncates to n. Used by Songs-like-X (and as the fallback inside // pickHeadAndTail for small pools). -func pickTopN(cands []recommendation.Candidate, dateStr string, now time.Time, n int) []rankedCandidate { - sorted := scoreAndSortCandidates(cands, dateStr, now) +func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate { + sorted := scoreAndSortCandidates(cands, userID, dateStr, now) capped := capCandidatesByAlbumAndArtist(sorted) if len(capped) > n { capped = capped[:n] } + rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) out := make([]rankedCandidate, len(capped)) for i, c := range capped { out[i] = rankedCandidate{ TrackID: c.Track.ID, - Score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG), + Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64), } } return out @@ -411,9 +481,10 @@ func pickTopN(cands []recommendation.Candidate, dateStr string, now time.Time, n // Falls back to standard pickTopN behavior when the candidate pool is too // small to support a meaningful head/tail split (capped pool <= // headN+tailN, or no candidates at or beyond position 2*headN). -func pickHeadAndTail(cands []recommendation.Candidate, dateStr string, now time.Time, headN, tailN int) []rankedCandidate { - sorted := scoreAndSortCandidates(cands, dateStr, now) +func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int) []rankedCandidate { + sorted := scoreAndSortCandidates(cands, userID, dateStr, now) capped := capCandidatesByAlbumAndArtist(sorted) + rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) total := headN + tailN if len(capped) <= total { @@ -425,7 +496,7 @@ func pickHeadAndTail(cands []recommendation.Candidate, dateStr string, now time. for i := 0; i < total; i++ { out[i] = rankedCandidate{ TrackID: capped[i].Track.ID, - Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, noopRNG), + Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64), } } return out @@ -461,7 +532,7 @@ func pickHeadAndTail(cands []recommendation.Candidate, dateStr string, now time. for i, c := range combined { out[i] = rankedCandidate{ TrackID: c.Track.ID, - Score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG), + Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64), } } return out diff --git a/internal/playlists/system_cron.go b/internal/playlists/system_cron.go deleted file mode 100644 index 8ac11426..00000000 --- a/internal/playlists/system_cron.go +++ /dev/null @@ -1,61 +0,0 @@ -package playlists - -import ( - "context" - "log/slog" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// StartSystemPlaylistCron launches the in-process daily build loop in a -// goroutine. Caller passes a context that's cancelled on graceful shutdown. -// -// Lifecycle: -// 1. Clear any stale in_flight=true rows from a previously crashed process -// (safe at startup: by definition, nothing is running yet). -// 2. Run once at startup so users catch up after a server restart without -// waiting up to 24h. -// 3. Tick every 24h; runOnce on each tick. -// 4. Exit on context cancellation. -// -// Per-user errors are logged but don't halt the loop. Per-cycle errors (e.g., -// "list active users" failed) are logged but the next tick still fires. -func StartSystemPlaylistCron(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string) { - go func() { - q := dbq.New(pool) - if err := q.ClearStaleSystemPlaylistInFlight(ctx); err != nil { - logger.Warn("system playlist cron: startup recovery failed", "err", err) - } - - runOnce(ctx, pool, logger, dataDir) - - ticker := time.NewTicker(24 * time.Hour) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - runOnce(ctx, pool, logger, dataDir) - } - } - }() -} - -func runOnce(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string) { - q := dbq.New(pool) - users, err := q.ListActiveUsersForSystemPlaylists(ctx) - if err != nil { - logger.Error("system playlist cron: list active users failed", "err", err) - return - } - for _, userID := range users { - if err := BuildSystemPlaylists(ctx, pool, logger, userID, time.Now(), dataDir); err != nil { - logger.Warn("system playlist build failed in cron", - "user_id", uuidStringPL(userID), "err", err) - } - } -} diff --git a/internal/server/server.go b/internal/server/server.go index 699418ac..8e6f5730 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,6 +18,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" + "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" @@ -79,6 +80,16 @@ type Server struct { LibraryScanner *library.Scanner ScanCfg library.RunScanConfig Scheduler *library.Scheduler + // Bus is the live-event bus shared with background workers (the + // lidarr reconciler, scan scheduler) constructed in cmd/minstrel/main.go. + // When nil, Router() constructs a local fallback (test contexts). + Bus *eventbus.Bus + // PlaylistScheduler fires per-user daily system-playlist builds at + // 03:00 in each user's stored timezone (#392 Half B). Constructed + // in cmd/minstrel/main.go and threaded into the API handlers so + // PUT /api/me/timezone and POST /api/auth/register can call + // Refresh synchronously. + PlaylistScheduler *playlists.Scheduler } func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverArtBackfillCap int, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server { @@ -117,7 +128,18 @@ func (s *Server) Router() http.Handler { tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir) playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir) smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer")) - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender) + // Live-event bus for SSE subscribers (#392). Constructed per-process; + // producers in playevents / lidarrrequests / scanner publish into + // the same instance. Background workers (lidarr reconciler, scan + // scheduler) live in cmd/minstrel/main.go where they're constructed + // before Router() runs; they read s.Bus directly so they share this + // process's bus. When Router() runs before main set the bus (tests, + // or future contexts) we fall back to a fresh local instance. + bus := s.Bus + if bus == nil { + bus = eventbus.New() + } + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second diff --git a/internal/sync/compactor.go b/internal/sync/compactor.go new file mode 100644 index 00000000..e26943f1 --- /dev/null +++ b/internal/sync/compactor.go @@ -0,0 +1,85 @@ +// Periodic compactor for the library_changes log (#357 deferred +// follow-up). Every mutation in the library writes one row, so the +// table grows unbounded without a retention policy. This worker +// trims rows older than CompactorRetention on a daily tick. +// +// Why daily / 30 days: Flutter clients track a per-app cursor and +// pull deltas from the last seen id. As long as a client checks in +// at least once per retention window, it never sees the cursor go +// stale. Clients that drop offline longer than that hit the /api/ +// library/sync 410 fallback and do a full resync. 30 days is the +// established norm for similar offline-cache delta logs and matches +// the spec at docs/superpowers/specs/2026-05-09-flutter-offline-mode-design.md. + +package sync + +import ( + "context" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// CompactorRetention is the age past which library_changes rows are +// dropped. Mutable for tests; production runs should not change it +// without considering the corresponding client cursor-stale fallback. +var CompactorRetention = 30 * 24 * time.Hour + +// CompactorInterval is how often the worker checks. Daily is enough — +// the table grows on the order of a few thousand rows/day for an +// active library, well below a size where intra-day trimming matters. +var CompactorInterval = 24 * time.Hour + +// Compactor is a background worker that periodically deletes +// library_changes rows older than CompactorRetention. +type Compactor struct { + pool *pgxpool.Pool + logger *slog.Logger + interval time.Duration +} + +// NewCompactor builds a compactor with production defaults. +func NewCompactor(pool *pgxpool.Pool, logger *slog.Logger) *Compactor { + return &Compactor{ + pool: pool, + logger: logger, + interval: CompactorInterval, + } +} + +// Run blocks until ctx is cancelled, ticking every c.interval. +// Fires one tick immediately on startup so a long-running process +// that hasn't been compacted in a while catches up. +func (c *Compactor) Run(ctx context.Context) { + c.tickOnce(ctx) + t := time.NewTicker(c.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c.tickOnce(ctx) + } + } +} + +// tickOnce deletes rows older than CompactorRetention and logs the +// row count. Errors are logged at WARN; we never abort the loop on +// transient DB failures. +func (c *Compactor) tickOnce(ctx context.Context) { + cutoff := pgtype.Timestamptz{Time: time.Now().Add(-CompactorRetention), Valid: true} + rows, err := dbq.New(c.pool).DeleteOldLibraryChanges(ctx, cutoff) + if err != nil { + c.logger.Warn("library_changes compactor: delete failed", "err", err) + return + } + if rows > 0 { + c.logger.Info("library_changes compactor: trimmed rows", + "count", rows, "older_than", CompactorRetention) + } +} diff --git a/web/src/lib/api/me.ts b/web/src/lib/api/me.ts index 9134d778..f6004a31 100644 --- a/web/src/lib/api/me.ts +++ b/web/src/lib/api/me.ts @@ -53,3 +53,12 @@ export async function getAPIToken(): Promise { export async function regenerateAPIToken(): Promise { return api.post('/api/me/api-token', {}); } + +// Submits the browser's current IANA timezone for the authenticated +// user. Called from the auth store on login + bootstrap + once weekly +// (cadence tracked client-side in localStorage). Failures are +// non-fatal — the server's UTC fallback keeps the scheduler working +// until the next attempt. +export async function putMyTimezone(timezone: string): Promise { + await api.put('/api/me/timezone', { timezone }); +} diff --git a/web/src/lib/auth/store.svelte.ts b/web/src/lib/auth/store.svelte.ts index 990439fa..49903a20 100644 --- a/web/src/lib/auth/store.svelte.ts +++ b/web/src/lib/auth/store.svelte.ts @@ -1,4 +1,5 @@ import { api, type User, type LoginResponse } from '$lib/api/client'; +import { putMyTimezone } from '$lib/api/me'; import { queryClient } from '$lib/query/client'; import { signalSessionEnd } from './sessionEnd.svelte'; import { user, setUser } from './user.svelte'; @@ -7,9 +8,34 @@ import { user, setUser } from './user.svelte'; // callers keep working. New code can import directly from auth/user.svelte. export { user }; +// Weekly client-driven cadence for sending the browser's current IANA +// timezone to PUT /api/me/timezone (#392 Half B). Tracked in +// localStorage so the cadence survives tab restarts; bumped after each +// successful PUT. Failures are swallowed — server keeps its previous +// value (or 'UTC' default). +const TZ_LAST_SENT_KEY = 'minstrel.tz_last_sent_at'; +const WEEKLY_MS = 7 * 24 * 60 * 60 * 1000; + +async function sendTimezoneIfStale(): Promise { + if (typeof window === 'undefined') return; // SSR-safe no-op + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!tz) return; + const lastStr = window.localStorage.getItem(TZ_LAST_SENT_KEY); + const last = lastStr ? Number(lastStr) : 0; + if (Number.isFinite(last) && Date.now() - last < WEEKLY_MS) return; + await putMyTimezone(tz); + window.localStorage.setItem(TZ_LAST_SENT_KEY, String(Date.now())); + } catch (err) { + console.warn('tz send failed:', err); + } +} + export async function bootstrap(): Promise { try { setUser(await api.get('/api/me')); + // ignore: best-effort, runs in background + void sendTimezoneIfStale(); } catch { setUser(null); } @@ -18,6 +44,7 @@ export async function bootstrap(): Promise { export async function login(username: string, password: string): Promise { const res = await api.post('/api/auth/login', { username, password }); setUser(res.user); + void sendTimezoneIfStale(); } export async function register(opts: { @@ -35,6 +62,7 @@ export async function register(opts: { const res = await api.post('/api/auth/register', body); setUser(res.user); await bootstrap(); + void sendTimezoneIfStale(); } export async function forgotPassword(email: string): Promise { diff --git a/web/src/lib/auth/store.test.ts b/web/src/lib/auth/store.test.ts index cc81499c..c6356643 100644 --- a/web/src/lib/auth/store.test.ts +++ b/web/src/lib/auth/store.test.ts @@ -1,12 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { apiClientMock } from '../../test-utils/mocks/client'; -vi.mock('$lib/api/client', () => ({ - api: { - get: vi.fn(), - post: vi.fn(), - del: vi.fn() - } -})); +vi.mock('$lib/api/client', () => apiClientMock()); vi.mock('$lib/query/client', () => ({ queryClient: { clear: vi.fn() } diff --git a/web/src/lib/components/AlbumCard.test.ts b/web/src/lib/components/AlbumCard.test.ts index 39327a4c..307c2b52 100644 --- a/web/src/lib/components/AlbumCard.test.ts +++ b/web/src/lib/components/AlbumCard.test.ts @@ -3,11 +3,10 @@ import { render, screen, fireEvent } from '@testing-library/svelte'; import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types'; import { FALLBACK_COVER } from '$lib/media/covers'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; +import { apiClientMock } from '../../test-utils/mocks/client'; import { makeTrack } from '$test-utils/fixtures/track'; -vi.mock('$lib/api/client', () => ({ - api: { get: vi.fn() } -})); +vi.mock('$lib/api/client', () => apiClientMock()); vi.mock('$lib/player/store.svelte', () => ({ enqueueTracks: vi.fn(), playQueue: vi.fn() diff --git a/web/src/lib/components/AlbumMenu.test.ts b/web/src/lib/components/AlbumMenu.test.ts index 6db143d4..6fad4573 100644 --- a/web/src/lib/components/AlbumMenu.test.ts +++ b/web/src/lib/components/AlbumMenu.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import { readable } from 'svelte/store'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; +import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists'; import type { TrackRef } from '$lib/api/types'; import { makeTracks } from '$test-utils/fixtures/track'; @@ -14,12 +14,7 @@ vi.mock('$lib/api/albums', async (orig) => { return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) }; }); -vi.mock('$lib/api/playlists', () => ({ - createPlaylistsQuery: () => - readable({ data: { owned: [], public: [] }, isPending: false, isError: false }), - appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }), - createPlaylist: vi.fn().mockResolvedValue({ id: 'p1', name: 'New' }) -})); +vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('$lib/player/store.svelte', () => ({ enqueueTracks: vi.fn() diff --git a/web/src/lib/components/ArtistCard.test.ts b/web/src/lib/components/ArtistCard.test.ts index c92dbfae..aa299870 100644 --- a/web/src/lib/components/ArtistCard.test.ts +++ b/web/src/lib/components/ArtistCard.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; +import { apiClientMock } from '../../test-utils/mocks/client'; import type { ArtistRef, TrackRef } from '$lib/api/types'; import { makeTrack, makeTracks } from '$test-utils/fixtures/track'; -vi.mock('$lib/api/client', () => ({ - api: { get: vi.fn() } -})); +vi.mock('$lib/api/client', () => apiClientMock()); vi.mock('$lib/player/store.svelte', () => ({ playQueue: vi.fn(), enqueueTracks: vi.fn() diff --git a/web/src/lib/components/ArtistMenu.test.ts b/web/src/lib/components/ArtistMenu.test.ts index d8439841..15812307 100644 --- a/web/src/lib/components/ArtistMenu.test.ts +++ b/web/src/lib/components/ArtistMenu.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import { readable } from 'svelte/store'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; +import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists'; import type { TrackRef } from '$lib/api/types'; import { makeTracks } from '$test-utils/fixtures/track'; @@ -13,12 +13,7 @@ vi.mock('$lib/api/artists', () => ({ listArtistTracks: vi.fn().mockResolvedValue([]) })); -vi.mock('$lib/api/playlists', () => ({ - createPlaylistsQuery: () => - readable({ data: { owned: [], public: [] }, isPending: false, isError: false }), - appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }), - createPlaylist: vi.fn().mockResolvedValue({ id: 'p1', name: 'New' }) -})); +vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('$lib/player/store.svelte', () => ({ enqueueTracks: vi.fn() diff --git a/web/src/lib/components/CompactTrackCard.test.ts b/web/src/lib/components/CompactTrackCard.test.ts index 0dfb1532..0936ce2d 100644 --- a/web/src/lib/components/CompactTrackCard.test.ts +++ b/web/src/lib/components/CompactTrackCard.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import type { TrackRef } from '$lib/api/types'; -import { readable } from 'svelte/store'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; +import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists'; import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine'; import { makeTracks } from '$test-utils/fixtures/track'; @@ -14,12 +14,7 @@ vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' }) })); -vi.mock('$lib/api/playlists', () => ({ - createPlaylistsQuery: () => - readable({ data: { owned: [], public: [] }, isPending: false, isError: false }), - appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }), - createPlaylist: vi.fn().mockResolvedValue({ id: 'p1', name: 'New' }) -})); +vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('$lib/auth/store.svelte', () => ({ user: { get value() { return { id: 'u1', username: 'u', is_admin: false }; } } diff --git a/web/src/lib/components/PlaylistTrackRow.svelte b/web/src/lib/components/PlaylistTrackRow.svelte index 595a85fa..c6916061 100644 --- a/web/src/lib/components/PlaylistTrackRow.svelte +++ b/web/src/lib/components/PlaylistTrackRow.svelte @@ -5,6 +5,7 @@ import TrackMenu from './TrackMenu.svelte'; import type { PlaylistTrack } from '$lib/api/types'; import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef'; + import { player } from '$lib/player/store.svelte'; import { offsetToDelta } from './queue-row-math'; let { @@ -50,12 +51,20 @@ // the dragEnd handler skips by returning before onMove fires; visually // the user never sees the row move. const canDrag = $derived(isOwner && !isUnavailable); + + // "Now playing" highlight: matches QueueTrackRow's treatment so the + // user can see which row is current without reading the player bar. + // Unavailable rows (trackId === null) never match. + const isCurrent = $derived( + !isUnavailable && player.current?.id === row.track_id, + );
({ playNext: vi.fn(), enqueueTrack: vi.fn(), playQueue: vi.fn(), - playRadio: vi.fn() + playRadio: vi.fn(), + player: { current: undefined } })); import PlaylistTrackRow from './PlaylistTrackRow.svelte'; diff --git a/web/src/lib/components/TrackMenu.test.ts b/web/src/lib/components/TrackMenu.test.ts index 7a50a9dc..6e359649 100644 --- a/web/src/lib/components/TrackMenu.test.ts +++ b/web/src/lib/components/TrackMenu.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import { readable } from 'svelte/store'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; +import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists'; import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine'; import { makeTrack } from '$test-utils/fixtures/track'; @@ -29,12 +29,7 @@ vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' }) })); -vi.mock('$lib/api/playlists', () => ({ - createPlaylistsQuery: () => - readable({ data: { owned: [], public: [] }, isPending: false, isError: false }), - appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }), - createPlaylist: vi.fn().mockResolvedValue({ id: 'p1', name: 'New' }) -})); +vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('$lib/player/store.svelte', () => ({ playNext: vi.fn(), diff --git a/web/src/lib/components/TrackRow.svelte b/web/src/lib/components/TrackRow.svelte index 281b171f..7417e7d9 100644 --- a/web/src/lib/components/TrackRow.svelte +++ b/web/src/lib/components/TrackRow.svelte @@ -1,7 +1,7 @@