Merge pull request 'release v2026.05.13.0: SSE live updates + offline cache + per-item rendering' (#43) from dev into main

This commit was merged in pull request #43.
This commit is contained in:
2026-05-14 02:36:47 +00:00
105 changed files with 5140 additions and 784 deletions
+34 -2
View File
@@ -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(),
@@ -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<FabledSwordTheme>()!;
// #402 wire-up: any quarantine event (flag from a user / admin
// resolve / file delete / lidarr delete) refreshes the admin queue.
ref.listen<AsyncValue<LiveEvent>>(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,
@@ -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<FabledSwordTheme>()!;
// #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<AsyncValue<LiveEvent>>(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
@@ -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<HomeIndex> getHomeIndex() async {
final r = await _dio.get<Map<String, dynamic>>('/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<TrackRef> getTrack(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/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.
+8
View File
@@ -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<void> putTimezone(String timezone) async {
await _dio.put<void>('/api/me/timezone', data: {'timezone': timezone});
}
}
+6
View File
@@ -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<MinstrelApp> {
// 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);
});
}
+37 -4
View File
@@ -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<FlutterSecureStorage>(
(ref) => const FlutterSecureStorage(),
@@ -29,7 +34,12 @@ class AuthController extends AsyncNotifier<User?> {
_storage = ref.watch(secureStorageProvider);
final raw = await _storage.read(key: _kCurrentUser);
if (raw == null) return null;
return User.fromJson(jsonDecode(raw) as Map<String, dynamic>);
final user = User.fromJson(jsonDecode(raw) as Map<String, dynamic>);
// 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<void> setServerUrl(String url) async {
@@ -42,6 +52,8 @@ class AuthController extends AsyncNotifier<User?> {
await _storage.write(key: _kCurrentUser, value: userJson);
ref.invalidate(sessionTokenProvider);
state = AsyncData(User.fromJson(jsonDecode(userJson) as Map<String, dynamic>));
// ignore: unawaited_futures
_sendTimezoneIfStale();
}
Future<void> clearSession() async {
@@ -50,6 +62,27 @@ class AuthController extends AsyncNotifier<User?> {
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<void> _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 =
+101 -1
View File
@@ -115,6 +115,77 @@ class SyncMetadata extends Table {
Set<Column> 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<Column> 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<Column> 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<Column> 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<Column> 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);
}
},
);
}
+139
View File
@@ -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/<type>/: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 `<type>:<id>`) 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<String> _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<void> _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<void> _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<void> _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<void> _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<HydrationQueue>((ref) {
return HydrationQueue(ref);
});
+72 -2
View File
@@ -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<SyncResult?> {
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 = <String>[];
final playlistCoverIds = <String>[];
await db.transaction(() async {
// ---- Upserts ----
@@ -85,9 +97,12 @@ class SyncController extends AsyncNotifier<SyncResult?> {
upsertCount++;
}
for (final a in (upserts['album'] as List? ?? const [])) {
final m = a as Map<String, dynamic>;
await db.into(db.cachedAlbums).insertOnConflictUpdate(
_albumFromJson(a as Map<String, dynamic>),
_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<SyncResult?> {
upsertCount++;
}
for (final p in (upserts['playlist'] as List? ?? const [])) {
final m = p as Map<String, dynamic>;
await db.into(db.cachedPlaylists).insertOnConflictUpdate(
_playlistFromJson(p as Map<String, dynamic>),
_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<SyncResult?> {
);
});
// 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<SyncResult?> {
}
}
/// 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<void> _prewarmCovers(
List<String> albumIds,
List<String> 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'}
: <String, String>{};
final base =
baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final urls = <String>[
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<void> 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<String, dynamic> j) =>
CachedArtistsCompanion.insert(
id: j['id'] as String,
+127
View File
@@ -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<EntityRef?, String> 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<EntityRef?>:
// - 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<AlbumRef?, String>((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<ArtistRef?, String>((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<TrackRef?, String>((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 ?? '',
);
}
});
@@ -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),
+185 -102
View File
@@ -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<FabledSwordTheme>()!;
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<String> 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<TrackRef> _resolveSectionTracks(WidgetRef ref, List<String> ids) {
final out = <TrackRef>[];
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<FabledSwordTheme>()!;
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<Playlist> 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<AlbumRef> albums;
const _RecentlyAddedSection({required this.ids});
final List<String> 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<AlbumRef> albums;
final List<ArtistRef> artists;
const _RediscoverSection({required this.albumIds, required this.artistIds});
final List<String> albumIds;
final List<String> 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<TrackRef> tracks;
const _MostPlayedSection({required this.ids});
final List<String> 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<ArtistRef> artists;
const _LastPlayedSection({required this.ids});
final List<String> 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),
),
),
]);
@@ -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<LibraryApi>((ref) async {
return LibraryApi(await ref.watch(dioProvider.future));
});
final homeProvider = FutureProvider<HomeData>((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<HomeData>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedHomeSnapshotData, HomeData>(
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<String, dynamic>),
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<HomeIndex>((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<CachedHomeIndexData, HomeIndex>(
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<String> 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 = <String>[];
final rediscoverAlbums = <String>[];
final rediscoverArtists = <String>[];
final mostPlayedTracks = <String>[];
final lastPlayedArtists = <String>[];
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<String, dynamic> _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<String, dynamic> _artistToJson(ArtistRef a) => {
'id': a.id,
'name': a.name,
'sort_name': a.sortName,
'album_count': a.albumCount,
'cover_url': a.coverUrl,
};
Map<String, dynamic> _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().
+372 -65
View File
@@ -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<AlbumRef>>(_LibraryAlbumsNotifier.new);
final _historyProvider = FutureProvider<HistoryPage>((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<HistoryPage>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedHistorySnapshotData, HistoryPage>(
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<String, dynamic>),
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<wire.Paged<TrackRef>>((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<String> _idsForEntity(List<CachedLike> rows) =>
rows.map((r) => r.entityId).toList(growable: false);
final _likedTrackIdsProvider = StreamProvider<List<String>>((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<CachedLike, List<String>>(
driftStream: query,
fetchAndPopulate: () => _populateLikeIds(ref),
toResult: _idsForEntity,
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedTrackIds',
);
});
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listAlbums();
final _likedAlbumIdsProvider = StreamProvider<List<String>>((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<CachedLike, List<String>>(
driftStream: query,
fetchAndPopulate: () => _populateLikeIds(ref),
toResult: _idsForEntity,
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedAlbumIds',
);
});
final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listArtists();
final _likedArtistIdsProvider = StreamProvider<List<String>>((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<CachedLike, List<String>>(
driftStream: query,
fetchAndPopulate: () => _populateLikeIds(ref),
toResult: _idsForEntity,
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedArtistIds',
);
});
final _quarantineProvider = FutureProvider<List<QuarantineMineRow>>((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<void> _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<FabledSwordTheme>()!;
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<AsyncValue<LiveEvent>>(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<String> 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 = <TrackRef>[];
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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.')),
);
}
}
},
),
]),
);
}
@@ -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<FabledSwordTheme>()!;
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<void> _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);
}
}
@@ -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<FabledSwordTheme>()!;
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<void> _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);
}
}
@@ -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<void> 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<void> 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<PlayCircleButton> createState() => _PlayCircleButtonState();
}
class _PlayCircleButtonState extends State<PlayCircleButton> {
bool _starting = false;
Future<void> _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<FabledSwordTheme>()!;
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<Color>(fs.parchment),
),
)
: Icon(
Icons.play_arrow,
color: fs.parchment,
size: iconSize,
),
),
),
);
}
}
@@ -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<FabledSwordTheme>()!;
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),
]),
),
),
);
}
+41
View File
@@ -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<String> recentlyAddedAlbums;
final List<String> rediscoverAlbums;
final List<String> rediscoverArtists;
final List<String> mostPlayedTracks;
final List<String> lastPlayedArtists;
static const empty = HomeIndex(
recentlyAddedAlbums: [],
rediscoverAlbums: [],
rediscoverArtists: [],
mostPlayedTracks: [],
lastPlayedArtists: [],
);
factory HomeIndex.fromJson(Map<String, dynamic> j) {
List<String> 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'),
);
}
}
@@ -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<String, Color?> _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<Color?> 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<Color?> _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<AlbumColorCache>(
(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<Color?, String>(
(ref, albumId) async {
final cache = ref.watch(albumColorCacheProvider);
return cache.getOrExtract(albumId);
},
);
@@ -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<NowPlayingScreen> {
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<NowPlayingScreen> {
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<NowPlayingScreen> {
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<NowPlayingScreen> {
),
],
),
),
),
),
);
@@ -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,
),
),
),
);
+35 -17
View File
@@ -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<FabledSwordTheme>()!;
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(
@@ -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<FabledSwordTheme>()!;
// #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<AsyncValue<LiveEvent>>(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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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)),
]),
),
),
);
}
@@ -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<FabledSwordTheme>()!;
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<void> _playPlaylist(WidgetRef ref) async {
final api = await ref.read(playlistsApiProvider.future);
final detail = await api.get(playlist.id);
final refs = <TrackRef>[];
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);
}
}
@@ -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<QuarantineApi>((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<List<QuarantineMineRow>> {
@override
Future<List<QuarantineMineRow>> 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<void> _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<void> flag(TrackRef track, String reason, String notes) async {
final api = await ref.read(quarantineApiProvider.future);
final current = state.value ?? const <QuarantineMineRow>[];
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<void> unflag(String trackId) async {
final api = await ref.read(quarantineApiProvider.future);
final current = state.value ?? const <QuarantineMineRow>[];
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 =
@@ -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<FabledSwordTheme>()!;
// #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<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
final e = next.asData?.value;
if (e?.kind == 'request.status_changed') {
ref.invalidate(myRequestsProvider);
}
});
final requests = ref.watch(myRequestsProvider);
return Scaffold(
@@ -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<AsyncValue<LiveEvent>>(
liveEventsProvider,
(_, next) => next.whenData(_handle),
fireImmediately: false,
);
WidgetsBinding.instance.addObserver(this);
}
final Ref _ref;
late final ProviderSubscription<AsyncValue<LiveEvent>> _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();
}
}
@@ -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<String, dynamic> 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<LiveEvent>((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<LiveEvent>();
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<void> _runSubscription(Dio dio, StreamController<LiveEvent> controller) async {
try {
final resp = await dio.get<ResponseBody>(
'/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<String, dynamic>;
return LiveEvent(
kind: kind ?? (decoded['kind'] as String? ?? ''),
userId: decoded['user_id'] as String? ?? '',
data: (decoded['data'] as Map<String, dynamic>?) ?? const {},
);
} catch (_) {
return null;
}
}
+5
View File
@@ -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
@@ -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/<id>/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 <img> 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 <img> 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,
: <String, String>{};
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,
);
},
);
@@ -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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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),
],
),
),
],
),
),
);
}
}
@@ -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<VersionResult> {
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<VersionResult> 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<void> 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<void> 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<void> _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, VersionResult>(
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<VersionGate> createState() => _VersionGateState();
}
class _TooOldScreen extends StatelessWidget {
const _TooOldScreen();
class _VersionGateState extends ConsumerState<VersionGate>
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<FabledSwordTheme>()!;
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'),
),
]),
),
);
}
+49 -1
View File
@@ -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:
+16 -1
View File
@@ -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:
@@ -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.
}
@@ -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<QuarantineMineRow> _initial;
@override
Future<List<QuarantineMineRow>> 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<void> _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);
+5 -1
View File
@@ -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
+12 -2
View File
@@ -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=
+7
View File
@@ -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,
+2
View File
@@ -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))
}
+9 -1
View File
@@ -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
}
+11
View File
@@ -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{
+109
View File
@@ -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),
},
})
}
+135
View File
@@ -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: <kind>
// data: <json payload>
//
// where <kind> follows "domain.action" naming and <json payload>
// 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
}
+48
View File
@@ -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
+64
View File
@@ -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)
}
}
+2 -1
View File
@@ -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",
+6
View File
@@ -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)
}
+70
View File
@@ -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)
}
+100
View File
@@ -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)
}
}
+6
View File
@@ -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")
+5
View File
@@ -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)
}
+2
View File
@@ -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))
}
+18
View File
@@ -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 {
+7 -1
View File
@@ -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 {
+17
View File
@@ -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
+2
View File
@@ -522,6 +522,8 @@ type User struct {
DisplayName *string
AutoApproveRequests bool
Email *string
Timezone string
TimezoneUpdatedAt pgtype.Timestamptz
}
type UserInvite struct {
+31 -11
View File
@@ -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
+91 -11
View File
@@ -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
}
@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN timezone,
DROP COLUMN timezone_updated_at;
@@ -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;
+7 -1
View File
@@ -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;
+6
View File
@@ -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;
+13 -6
View File
@@ -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
+21
View File
@@ -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'
);
+96
View File
@@ -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; 1664 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)
}
+150
View File
@@ -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()
}
+81
View File
@@ -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)
}
+4
View File
@@ -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
}
+64 -8
View File
@@ -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 {
@@ -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)
}
+15 -5
View File
@@ -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
+26 -14
View File
@@ -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))
}
+255
View File
@@ -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
}
+51
View File
@@ -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)
}
}
+188
View File
@@ -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))
}
+105 -34
View File
@@ -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
-61
View File
@@ -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)
}
}
}
+23 -1
View File
@@ -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
+85
View File
@@ -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)
}
}
+9
View File
@@ -53,3 +53,12 @@ export async function getAPIToken(): Promise<APITokenResponse> {
export async function regenerateAPIToken(): Promise<APITokenResponse> {
return api.post<APITokenResponse>('/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<void> {
await api.put<void>('/api/me/timezone', { timezone });
}
+28
View File
@@ -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<void> {
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<void> {
try {
setUser(await api.get<User>('/api/me'));
// ignore: best-effort, runs in background
void sendTimezoneIfStale();
} catch {
setUser(null);
}
@@ -18,6 +44,7 @@ export async function bootstrap(): Promise<void> {
export async function login(username: string, password: string): Promise<void> {
const res = await api.post<LoginResponse>('/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<LoginResponse>('/api/auth/register', body);
setUser(res.user);
await bootstrap();
void sendTimezoneIfStale();
}
export async function forgotPassword(email: string): Promise<void> {
+2 -7
View File
@@ -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() }
+2 -3
View File
@@ -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()
+2 -7
View File
@@ -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()
+2 -3
View File
@@ -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()
+2 -7
View File
@@ -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()
@@ -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 }; } }
+10 -1
View File
@@ -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,
);
</script>
<div
role="listitem"
class="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-surface-hover
{isUnavailable ? 'text-text-muted' : 'text-text-primary'}"
{isUnavailable ? 'text-text-muted' : 'text-text-primary'}
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
use:draggable={{
axis: 'y',
bounds: 'parent',
@@ -29,7 +29,8 @@ vi.mock('$lib/player/store.svelte', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playQueue: vi.fn(),
playRadio: vi.fn()
playRadio: vi.fn(),
player: { current: undefined }
}));
import PlaylistTrackRow from './PlaylistTrackRow.svelte';
+2 -7
View File
@@ -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(),
+9 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { TrackRef } from '$lib/api/types';
import { formatDuration } from '$lib/media/duration';
import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import { player, playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import { Plus } from 'lucide-svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
@@ -16,6 +16,13 @@
const track = $derived(tracks[index]);
// Mirrors QueueTrackRow's "now playing" treatment so the user sees
// which row in the album / liked / history / search view is currently
// playing without having to read the player bar. Only the accent
// border + bg lift — no "Now playing" pill, since the player bar
// already names the track.
const isCurrent = $derived(player.current?.id === track.id);
function activate() {
if (onPlay) onPlay(tracks, index);
else playQueue(tracks, index);
@@ -49,7 +56,7 @@
aria-label={track.title}
onclick={onRowClick}
onkeydown={onRowKey}
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
>
<span class="text-right tabular-nums text-text-secondary">
{track.track_number ?? '—'}
+2 -1
View File
@@ -8,7 +8,8 @@ import { makeTrack } from '$test-utils/fixtures/track';
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn(),
enqueueTrack: vi.fn(),
playRadio: vi.fn()
playRadio: vi.fn(),
player: { current: undefined }
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
+2 -3
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import { apiClientMock } from '../../test-utils/mocks/client';
import type { LidarrSearchResult } from '$lib/api/types';
// Lidarr search query factory and createRequest are mocked at the module
@@ -18,9 +19,7 @@ vi.mock('$lib/api/requests', () => ({
createRequest: vi.fn().mockResolvedValue({ id: 'r1' })
}));
vi.mock('$lib/api/client', () => ({
api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), del: vi.fn() }
}));
vi.mock('$lib/api/client', () => apiClientMock());
import DiscoverPage from './+page.svelte';
import { createLidarrSearchQuery } from '$lib/api/lidarr';
+2 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import { emptyLikesMock } from '../../../test-utils/mocks/likes';
import { apiClientMock } from '../../../test-utils/mocks/client';
vi.mock('$lib/api/albums', () => ({
createAlbumsAlphaInfiniteQuery: () => readable({
@@ -25,7 +26,7 @@ vi.mock('$lib/api/albums', () => ({
}),
ALBUM_PAGE_SIZE: 50
}));
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()
+2 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import { emptyLikesMock } from '../../../test-utils/mocks/likes';
import { apiClientMock } from '../../../test-utils/mocks/client';
vi.mock('$lib/api/queries', () => ({
createArtistsQuery: () => readable({
@@ -20,7 +21,7 @@ vi.mock('$lib/api/queries', () => ({
fetchNextPage: () => {}
})
}));
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()
+4 -2
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query';
import { apiClientMock } from '../../../test-utils/mocks/client';
import type { ArtistRef, AlbumRef, TrackRef, Page } from '$lib/api/types';
import { readable } from 'svelte/store';
import { makeTrack } from '$test-utils/fixtures/track';
@@ -18,9 +19,10 @@ vi.mock('$lib/api/likes', () => ({
unlikeEntity: vi.fn()
}));
vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } }));
vi.mock('$lib/api/client', () => apiClientMock());
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn()
playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn(),
player: { current: undefined }
}));
import LikedPage from './+page.svelte';
@@ -30,7 +30,8 @@ vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn(),
enqueueTracks: vi.fn(),
playNext: vi.fn(),
enqueueTrack: vi.fn()
enqueueTrack: vi.fn(),
player: { current: undefined }
}));
// Cascade through PlaylistTrackRow's mocks (LikeButton + TrackMenu transitively).
+2 -3
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import { apiClientMock } from '../../test-utils/mocks/client';
import type { LidarrRequest } from '$lib/api/types';
// Capture invalidateQueries on the mocked QueryClient so the cancel test can
@@ -23,9 +24,7 @@ vi.mock('$lib/api/queries', () => ({
}
}));
vi.mock('$lib/api/client', () => ({
api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), del: vi.fn() }
}));
vi.mock('$lib/api/client', () => apiClientMock());
import RequestsPage from './+page.svelte';
import { createMyRequestsQuery, cancelRequest } from '$lib/api/requests';

Some files were not shown because too many files have changed in this diff Show More