Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126

Merged
bvandeusen merged 23 commits from dev into main 2026-08-17 16:28:14 -04:00
77 changed files with 89 additions and 131 deletions
Showing only changes of commit 03a8d12079 - Show all commits
@@ -11,8 +11,6 @@ import javax.inject.Singleton
/**
* Read-through accessor for the admin cross-user requests queue.
* Mirrors `flutter_client/lib/admin/admin_providers.dart`'s
* AdminRequestsController.
*
* No Room caching — admin actions are infrequent and don't benefit
* from offline scrollback. `approve` and `reject` fire direct REST
@@ -8,8 +8,7 @@ import java.io.IOException
/**
* Maps server error codes (and common transport failures) to
* friendly, sentence-case copy. Mirrors
* `flutter_client/assets/error-copy.json` + `error_copy.dart`.
* friendly, sentence-case copy.
*
* Server errors are `{"error":{"code":"...","message":"..."}}`.
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
@@ -10,8 +10,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/invites`. Mirrors
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
* Retrofit interface for `/api/admin/invites`.
*
* Server TTL is hardcoded at 24h; the only configurable field is the
* optional `note` on create.
@@ -6,8 +6,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/quarantine`. Mirrors
* `flutter_client/lib/api/endpoints/admin_quarantine.dart`.
* Retrofit interface for `/api/admin/quarantine`.
*
* Three resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags).
@@ -6,8 +6,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/requests`. Mirrors
* `flutter_client/lib/api/endpoints/admin_requests.dart`.
* Retrofit interface for `/api/admin/requests`.
*
* Server returns the same `requestView` shape as the user-side
* `/api/requests`, so RequestWire is reused. Different listing scope —
@@ -10,8 +10,7 @@ import retrofit2.http.PUT
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/users`. Mirrors
* `flutter_client/lib/api/endpoints/admin_users.dart`.
* Retrofit interface for `/api/admin/users`.
*
* Note: the PUT-auto-approve body field is `auto_approve`, NOT
* `auto_approve_requests` — the request shape differs from the
@@ -6,8 +6,7 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `/api/auth`. Mirrors
* `flutter_client/lib/api/endpoints/auth.dart`.
* Retrofit interface for `/api/auth`.
*
* The actual session-cookie capture happens in
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
@@ -14,7 +14,6 @@ import retrofit2.http.Query
/**
* Retrofit interface for Discover / Lidarr search / request creation.
* Mirrors `flutter_client/lib/api/endpoints/discover.dart`.
*
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
* of the same query are cheap.
@@ -9,8 +9,7 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `POST /api/events`. Mirrors the relevant
* slice of `flutter_client/lib/api/endpoints/events.dart`. All four
* Retrofit interface for `POST /api/events`. All four
* variants share the same URL — the discriminator is in the request
* body's `type` field. Server contract is best-effort per spec;
* callers (the live path in PlayEventsReporter) swallow errors and
@@ -5,10 +5,9 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/me/history`. Mirrors the relevant
* subset of `flutter_client/lib/api/endpoints/me.dart` (only
* `history()`; profile / timezone / quarantine endpoints land with
* their respective phases).
* Retrofit interface for `/api/me/history` — history only. The profile,
* timezone and quarantine endpoints on `/api/me` live with their own
* features rather than here.
*/
interface HistoryApi {
@GET("api/me/history")
@@ -4,10 +4,9 @@ import com.fabledsword.minstrel.models.wire.HomeIndexWire
import retrofit2.http.GET
/**
* Retrofit interface for the Home discovery endpoint. Mirrors
* `flutter_client/lib/api/endpoints/home.dart` — just the ID-only
* `/api/home/index` variant. The Flutter port has a heavier
* `/api/home` (full embedded payload) too; we don't use it because
* Retrofit interface for the Home discovery endpoint. Only the ID-only
* `/api/home/index` variant is used. The server also serves a heavier
* `/api/home` (full embedded payload); we don't use it because
* the per-item hydration path (sync controller → Room → Flow) is
* the only one the native client needs.
*/
@@ -13,7 +13,6 @@ import retrofit2.http.Query
/**
* Retrofit interface for the server's native `/api/...` library surface.
* Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1.
*
* Notes on shapes:
* - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields
@@ -7,8 +7,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/likes`. Mirrors
* `flutter_client/lib/api/endpoints/likes.dart`.
* Retrofit interface for `/api/likes`.
*
* Path segment `kind` is one of "artists" | "albums" | "tracks"
* (plural, matching the server route). The Repository hides that
@@ -11,7 +11,6 @@ import retrofit2.http.PUT
/**
* Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints.
* Mirrors the relevant slice of `flutter_client/lib/api/endpoints/settings.dart`.
*
* History + timezone + system-playlists-status live under /api/me too
* but are handled by their respective feature repositories; this
@@ -11,8 +11,7 @@ import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for `/api/playlists`. Mirrors
* `flutter_client/lib/api/endpoints/playlists.dart`.
* Retrofit interface for `/api/playlists`.
*/
interface PlaylistsApi {
/**
@@ -54,7 +53,7 @@ interface PlaylistsApi {
* the system playlist's tracks in rotation-aware order without
* rebuilding — used by the Home play-button overlay so taps on For
* You / Discover / Today's mix advance rotation rather than picking
* the stored order. Mirrors `playlists.dart.systemShuffle`.
* the stored order.
*/
@GET("api/playlists/system/{kind}/shuffle")
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
@@ -8,9 +8,8 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/quarantine`. Mirrors the relevant
* parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag
* and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`.
* Retrofit interface for `/api/quarantine`: flag and unflag, plus the
* `/api/quarantine/mine` listing.
*
* Both flag and unflag are user-scoped — callers act on their own
* quarantine entries. The cross-user admin surface is a separate
@@ -5,9 +5,7 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/radio`. Mirrors the relevant slice of
* `flutter_client/lib/api/endpoints/radio.dart` (a single GET that
* returns the seeded queue). The server picks a fresh shuffle each
* Retrofit interface for `/api/radio`. The server picks a fresh shuffle each
* invocation — clients call this once per radio start.
*/
interface RadioApi {
@@ -6,8 +6,7 @@ import retrofit2.http.GET
import retrofit2.http.Path
/**
* Retrofit interface for the user-side `/api/requests`. Mirrors
* `flutter_client/lib/api/endpoints/requests.dart`.
* Retrofit interface for the user-side `/api/requests`.
*
* Server scopes results to the caller — admins see only their own
* requests through this endpoint. The cross-user admin view lives on
@@ -5,8 +5,7 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `GET /api/search`. Mirrors
* `flutter_client/lib/api/endpoints/search.dart`. Server returns 400
* Retrofit interface for `GET /api/search`. Server returns 400
* on empty/whitespace-only `q` — the caller is responsible for
* guarding.
*/
@@ -17,8 +17,7 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Singleton facade over the auth state machine. Mirrors Flutter's
* `AuthController` from `auth_provider.dart`.
* Singleton facade over the auth state machine.
*
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
* Set-Cookie on the login response; the user identity itself
@@ -12,8 +12,7 @@ import javax.inject.Singleton
private const val POOL_LIMIT = 100
/**
* Offline play sources over the local audio-cache index. Mirrors
* `flutter_client/lib/cache/shuffle_source.dart`.
* Offline play sources over the local audio-cache index.
*
* Both pools are UNIONs over the cache regardless of storage bucket
* (liked AND recently-played both included). The two-bucket split is
@@ -1,7 +1,7 @@
package com.fabledsword.minstrel.cache.audiocache
/**
* Defaults for the 2-bucket audio cache. Matches the Flutter client.
* Defaults for the 2-bucket audio cache.
*
* - `likedCapBytes`: cap for the protected bucket — cached files for
* tracks the user has liked. Evicted only after the rolling bucket
@@ -6,8 +6,7 @@ private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
private const val DEFAULT_PREFETCH_WINDOW = 5
/**
* User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
* (cache_settings_provider.dart) field-for-field. Persisted as a JSON
* User-tunable audio cache settings. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore].
*
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
@@ -59,7 +59,6 @@ interface CachedPlaylistDao {
/**
* Atomically reconciles the cache against the fresh list response.
* Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` —
* `BuildSystemPlaylists` rotates system-playlist UUIDs every
* rebuild, so upsert alone leaves stale rows whose detail fetch
* 404s. Delete any of the user's rows not in [freshOwnedIds] (this
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
/**
* One row per fully-downloaded audio file. Mirrors
* `flutter_client/lib/cache/db.dart`'s `AudioCacheIndex` Drift table.
* the Flutter client's `AudioCacheIndex` Drift table.
*
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
* - `incidental` files (streamed-and-cached side effect) evict first
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one album. Mirrors `flutter_client/lib/cache/db.dart`'s
* Cache row for one album. Mirrors the Flutter client's
* `CachedAlbums` Drift table.
*/
@Entity(tableName = "cached_albums")
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s
* Cache row for one artist. Mirrors the Flutter client's
* `CachedArtists` Drift table.
*
* Column names follow Kotlin idiom (camelCase) rather than Drift's
@@ -6,7 +6,7 @@ import kotlinx.datetime.Instant
/**
* Per-item row driving the Home screen sections. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedHomeIndex` Drift table.
* the Flutter client's `CachedHomeIndex` Drift table.
*
* `section` is one of (matching /api/home keys):
* - "recently_added_albums"
@@ -5,7 +5,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Like membership row. Mirrors `flutter_client/lib/cache/db.dart`'s
* Like membership row. Mirrors the Flutter client's
* `CachedLikes` Drift table. Composite primary key — one user may
* independently like a track AND its album AND its artist; rows are
* disambiguated by the (userId, entityType, entityId) triple.
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/**
* One row per pending offline-write. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedMutations` Drift table.
* the Flutter client's `CachedMutations` Drift table.
*
* MutationQueue.enqueue() inserts a row when a server-write fails with
* an IOException; MutationReplayer.drain() pops and re-attempts each
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/**
* Cache row for one playlist (user or system). Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylists` Drift table.
* the Flutter client's `CachedPlaylists` Drift table.
*
* `systemVariant` is null for user playlists and one of
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
@@ -4,7 +4,7 @@ import androidx.room.Entity
/**
* Ordered membership of tracks within a playlist. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylistTracks` Drift table.
* the Flutter client's `CachedPlaylistTracks` Drift table.
* Composite PK so the same track can only appear once per playlist;
* `position` carries the ordering.
*/
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/**
* The current user's quarantine flag for one track. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedQuarantineMine` Drift
* the Flutter client's `CachedQuarantineMine` Drift
* table.
*
* The flat denormalized track/album/artist columns let the Quarantine
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
/**
* Single-row snapshot of the last playback session — queue (as JSON),
* current index, position, and source tag. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedResumeState` Drift table.
* the Flutter client's `CachedResumeState` Drift table.
*
* Lets a torn-down session (the player's idle/dismissed teardown)
* resume on next launch; without it the headset / lock-screen play
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one track. Mirrors `flutter_client/lib/cache/db.dart`'s
* Cache row for one track. Mirrors the Flutter client's
* `CachedTracks` Drift table.
*/
@Entity(tableName = "cached_tracks")
@@ -38,8 +38,7 @@ private const val BACKOFF_FACTOR = 2
* ViewModels + the central [LiveEventsDispatcher]) collect filtered
* subsets of the stream.
*
* Connection lifecycle mirrors
* `flutter_client/lib/shared/live_events_provider.dart`:
* Connection lifecycle:
* - Gated on having a session cookie. Subscription opens when the
* cookie transitions to non-null and closes when it transitions
* back to null (sign-out).
@@ -5,7 +5,7 @@ import kotlinx.serialization.json.JsonObject
/**
* Parsed event from the server's SSE stream. Mirrors
* `flutter_client/lib/shared/live_events_provider.dart`'s `LiveEvent`.
* the Flutter client's `LiveEvent`.
*
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
* - [userId] is the actor whose user-scoped state changed (empty for
@@ -11,8 +11,7 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Mirrors
* `flutter_client/lib/shared/live_events_dispatcher.dart`. Activated
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Activated
* by force-@Inject in MinstrelApplication.
*
* Scope is deliberately narrow: this dispatcher only touches state
@@ -204,8 +204,7 @@ private const val HOURS_PER_DAY = 24L
private const val DAYS_PER_WEEK = 7L
/**
* Lightweight relative-time formatter mirroring Flutter's
* `library_screen.dart`'s `_relativeTime`:
* Lightweight relative-time formatter:
*
* < 1h → "Nm ago"
* < 24h → "Nh ago"
@@ -1172,7 +1172,7 @@ enum class OfflinePoolKind(val label: String) {
* first / greyed after, and the "building/pending" placeholders are dropped
* (they need the server to generate, so they're meaningless offline).
*
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
* Diverges from Flutter (the Flutter client
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
@@ -1374,7 +1374,7 @@ private const val MOST_PLAYED_COVER_DP = 48
// 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
// rounded up. Mirrors Flutter (`CompactTrackCard` in
// flutter_client/lib/library/widgets/compact_track_card.dart) which
// the Flutter client) which
// uses a horizontal-row card pattern - much denser than the square
// per-track tiles that web uses (operator request 2026-06-01: "in the
// flutter iteration the tiles were different and smaller so more of
@@ -55,7 +55,7 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
* Library tab. Seven-tab TabBar (Artists / Albums / Genres / Years /
* History / Liked / Hidden), matching the web client's library tab bar.
* Genres and Years arrived with #2467; the rest predate it and mirrored
* `flutter_client/lib/library/library_screen.dart`.
* the Flutter client.
*
* Artists + Albums are wired against the existing LibraryViewModel
* (cache-first reads of cached_artists / cached_albums). The other
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Lightweight reference to one album. Mirrors
* `flutter_client/lib/models/album.dart`'s `AlbumRef`.
* the Flutter client's `AlbumRef`.
*
* `coverUrl` and `durationSec` match the server contract (not
* `cover_art_url` / `duration_ms`). `year` is omitempty server-side so
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Lightweight reference to one artist. Mirrors
* `flutter_client/lib/models/artist.dart`'s `ArtistRef`.
* the Flutter client's `ArtistRef`.
*
* `coverUrl` is the server's field name (NOT cover_art_url). Server emits
* empty string when the artist has no representative album cover; UI code
@@ -14,7 +14,7 @@ enum class LidarrRequestKind {
}
/**
* Lidarr search hit. Mirrors `flutter_client/lib/models/lidarr.dart`'s
* Lidarr search hit. Mirrors the Flutter client's
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
* and `albumMbid` are filled when the row is an album/track and the
* UI needs the parent IDs to build the request.
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Domain shape for one admin-issued registration invite. Mirrors
* `flutter_client/lib/models/invite.dart Invite` and the server's
* the Flutter client's `Invite` and the server's
* `inviteResp` from `internal/api/admin_invites.go`.
*
* `invitedBy` and `redeemedBy` are UUIDs of users (not usernames);
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Caller's ListenBrainz integration state. Mirrors
* `flutter_client/lib/models/my_profile.dart ListenBrainzStatus`
* the Flutter client's `ListenBrainzStatus`
* and the server's `listenBrainzResp`.
*
* The token itself is never read back from the server — `tokenSet`
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Lightweight reference to one playlist (user or system-generated).
* Mirrors `flutter_client/lib/models/playlist.dart`'s `Playlist`.
* Mirrors the Flutter client's `Playlist`.
*
* `systemVariant` discriminates user vs. system playlists — null for
* user-owned, one of "for_you" / "discover" / "songs_like_artist" / etc.
@@ -24,7 +24,7 @@ enum class RequestStatus {
/**
* One Lidarr request the user has submitted. Mirrors
* `flutter_client/lib/models/admin_request.dart AdminRequest` —
* the Flutter client's `AdminRequest` —
* shared between the user-side `/api/requests` view and the admin
* cross-user view since the wire shape is identical.
*
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.models
/**
* Caller's most recent system_playlist_runs state, driving the Home
* placeholder cards for not-yet-generated system playlists. Mirrors
* `flutter_client/lib/models/system_playlists_status.dart` and the
* server's `systemPlaylistsStatusResp`.
* the server's `systemPlaylistsStatusResp`.
*
* Zero values (inFlight=false, both timestamps null) mean the user
* has never had a build attempted — the placeholders read as
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
/**
* Lightweight reference to one track. Mirrors
* `flutter_client/lib/models/track.dart`'s `TrackRef`.
* the Flutter client's `TrackRef`.
*
* The `Ref` suffix matches the Flutter convention — these types carry
* only the IDs + display fields needed for list rendering + the player
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Wire shape returned by `GET /api/client/version`. Mirrors
* `flutter_client/lib/update/update_info.dart UpdateInfo`.
* the Flutter client's `UpdateInfo`.
*
* `version` is the server-bundled APK version (may have a leading
* "v" from the git tag); `apkUrl` is server-relative (e.g.
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `AlbumRef`. Mirrors `flutter_client/lib/models/album.dart`.
* Wire shape for `AlbumRef`.
*/
@Serializable
data class AlbumWire(
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `ArtistRef`. Mirrors `flutter_client/lib/models/artist.dart`.
* Wire shape for `ArtistRef`.
*/
@Serializable
data class ArtistWire(
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
/**
* One row of `GET /api/lidarr/search`. Mirrors
* `web/src/lib/api/types.ts LidarrSearchResult` /
* `flutter_client/lib/models/lidarr.dart LidarrSearchResult`.
* the Flutter client's `LidarrSearchResult`.
*
* `inLibrary` and `requested` let the UI greyout rows the user can't
* act on (already imported / already awaiting review). All defaults
@@ -9,8 +9,7 @@ import kotlinx.serialization.Serializable
/**
* Wire shapes for `POST /api/events`. The endpoint multiplexes four
* variants on the `type` discriminator field, mirroring
* `flutter_client/lib/api/endpoints/events.dart`.
* variants on the `type` discriminator field.
*
* play_started returns the server-assigned play_event_id (nullable —
* server may suppress under certain conditions); the other three
@@ -5,9 +5,8 @@ import kotlinx.serialization.Serializable
/**
* Wire shape of `GET /api/home/index` — five flat slices of entity-ID
* strings, one per Home section. Mirrors
* `flutter_client/lib/models/home_index.dart` (and the server's
* `internal/api/types.go HomeIndexPayload`).
* strings, one per Home section. Mirrors the server's
* `HomeIndexPayload` in `internal/api/types.go`.
*
* Section name implies entity type; no per-entry type tag is needed:
* - recentlyAddedAlbums → album
@@ -5,8 +5,7 @@ import kotlinx.serialization.Serializable
/**
* Wire shape for `GET /api/me` and the return value of
* `PUT /api/me/profile`. Mirrors
* `flutter_client/lib/models/my_profile.dart`:
* `PUT /api/me/profile`. Two things the shape assumes:
* - `display_name` and `email` are nullable; server returns null
* when the user hasn't set them yet (registration only requires
* a username).
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
/**
* One row of `GET /api/quarantine/mine`. Mirrors
* `flutter_client/lib/models/quarantine_mine.dart QuarantineMineRow`
* the Flutter client's `QuarantineMineRow`
* (web `LidarrQuarantineMineRow`).
*
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
@@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
* Wire shape of `requestView` from `internal/api/requests.go` — the
* row returned by both `GET /api/requests` (caller's own requests) and
* `GET /api/admin/requests` (cross-user admin view). Mirrors
* `flutter_client/lib/models/admin_request.dart AdminRequest`.
* the Flutter client's `AdminRequest`.
*
* Status values: `pending` / `approved` / `rejected` / `completed` /
* `failed`. Kind values: `artist` / `album` / `track`.
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
/**
* Wire shape for `TrackRef` as the server emits it. Mirrors
* `flutter_client/lib/models/track.dart`'s `TrackRef.fromJson`
* the Flutter client's `TrackRef.fromJson`
* field-for-field; the keys are snake_case because the server is Go
* (json:"album_id" etc.).
*
@@ -25,7 +25,7 @@ import javax.inject.Singleton
* Pre-downloads the next-N tracks in the queue into the shared Media3
* [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or
* natural advance plays from disk instead of waiting on a fresh HTTP
* connection. Mirrors the Flutter `Prefetcher` (cache/prefetcher.dart):
* connection. Behaviour:
* watches the player's current track, walks forward by
* [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow]
* tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when
@@ -23,7 +23,7 @@ private const val DEBOUNCE_MS = 2_000L
* operator never finds out the track is bad and the next user hits
* the same wall.
*
* The snackbar text mirrors Flutter's `playback_error_reporter.dart`:
* The snackbar text:
* collect [PlayerController.playbackErrorEvents], debounce in a 2s
* window, emit "Couldn't play 'X' — skipping" for a single error or
* "Skipped N unplayable tracks" when a burst lands inside the window.
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.models.TrackRef
/**
* Cycle on the repeat button: off → all → one → off. Mirrors
* `AudioServiceRepeatMode` in flutter_client and maps directly to
* Cycle on the repeat button: off → all → one → off. Maps directly to
* the three Media3 `Player.REPEAT_MODE_*` int constants.
*/
enum class RepeatMode { OFF, ALL, ONE }
@@ -15,8 +15,7 @@ import javax.inject.Singleton
/**
* Persists the player's last queue + position to Room so a torn-down
* session can resume on next app launch. Mirrors
* `flutter_client/lib/cache/resume_controller.dart`.
* session can resume on next app launch.
*
* Subscribes to [PlayerController.uiState] in init; persists when the
* (track-id, queueIndex) changes — captures real session transitions
@@ -30,8 +30,8 @@ private const val GRADIENT_TWEEN_MS = 600
* The held color is NOT reset when [coverUrl] changes — it stays on
* the previous track's dominant until the new palette resolves, so the
* gradient tweens old→new directly instead of dipping toward the
* fallback mid-swap. Mirrors `now_playing_screen.dart`'s preload-then-
* swap ("keep the previous dominant"); the cover image swaps smoothly
* fallback mid-swap — preload-then-swap, keeping the previous dominant
* until the new one resolves. The cover image swaps smoothly
* via `CoverPrefetcher`, which warms the next track's bytes into Coil.
*
* Starts at [Color.Transparent] (cold mount) and resets to it only
@@ -55,8 +55,9 @@ private const val COVER_SIZE_DP = 48
private const val SCRUBBER_ROW_HEIGHT_DP = 4
// Upward flick speed (dp/s) that expands the bar into NowPlaying.
// Mirrors player_bar.dart's 200 px/s threshold; expressed in dp and
// converted via density so the gesture feels the same across screens.
// The 200 px/s threshold carries over from the Flutter player bar,
// expressed in dp and converted via density so the gesture feels the
// same across screens.
private const val SWIPE_UP_VELOCITY_DP = 200
@OptIn(ExperimentalSharedTransitionApi::class)
@@ -129,7 +130,7 @@ fun MiniPlayer(
.collectAsStateWithLifecycle(initialValue = false)
// Swipe up anywhere on the bar to expand into the full player —
// mirrors player_bar.dart. We only act on a clear upward flick
// We only act on a clear upward flick
// (negative velocity past the threshold) so a slow tap-with-jitter
// doesn't accidentally open the screen. The horizontal seek slider
// keeps its own gestures; a vertical draggable only claims
@@ -371,9 +371,7 @@ private fun NowPlayingContent(
TrackHeader(title = track.title, artist = track.artistName, album = track.albumTitle)
Spacer(Modifier.height(24.dp))
// Action row (like, shuffle, repeat, queue, kebab) sits ABOVE the
// scrubber — Flutter's _SecondaryControls placement
// (now_playing_screen.dart:464). Android previously had it below
// the transport row.
// scrubber. Android previously had it below the transport row.
BottomActionsRow(
navController = navController,
track = track,
@@ -88,7 +88,7 @@ class PlaylistsRepository @Inject constructor(
// Reconcile: BuildSystemPlaylists rotates system-playlist
// UUIDs every rebuild, so upsert alone leaves stale rows
// whose detail fetch 404s ("That playlist no longer
// exists"). Mirrors playlists_provider.dart's deleteWhere.
// exists").
playlistDao.replaceList(
userId = userId,
freshOwnedIds = wire.owned.map { it.id },
@@ -111,8 +111,7 @@ class PlaylistsRepository @Inject constructor(
api.get(id)
} catch (e: HttpException) {
// Server says this playlist is gone — drop the stale cache
// row so the list stops showing it. Mirrors
// playlists_provider.dart's deleteWhere on detail failure.
// row so the list stops showing it.
if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) {
playlistDao.deleteByIds(listOf(id))
}
@@ -30,7 +30,6 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/**
* One playlist tile. Sized to match `AlbumCard` (176dp wide, 144dp
* square cover) so they line up in the Home Playlists carousel.
* Mirrors `flutter_client/lib/playlists/widgets/playlist_card.dart`.
*
* System playlists carry a small "For You" / "Discover" / etc. label
* subtitle under the name (substituting for the artist-name line on
@@ -28,7 +28,6 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/**
* Placeholder tile for a system playlist that hasn't generated yet.
* Mirrors `flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart`.
* Sized to match [PlaylistCard] so the Home Playlists row stays
* visually consistent.
*
@@ -231,7 +231,7 @@ private fun CancelConfirmDialog(
@Composable
private fun KindAvatar(kind: String) {
// Flutter mapping: disc-3 for artist, library-big for album,
// music for track. Mirrors lib/requests/requests_screen.dart.
// music for track.
val icon = when (kind) {
"artist" -> Lucide.Disc3
"album" -> Lucide.LibraryBig
@@ -15,9 +15,6 @@ import androidx.compose.ui.unit.dp
/**
* Labeled horizontal-scroll section used throughout the Home screen.
* Mirrors `flutter_client/lib/library/widgets/horizontal_scroll_row.dart`
* — a Fraunces section title at 16dp gutter, then a horizontal LazyRow
* with the same gutter and 8dp inter-item spacing.
*
* Pass empty `title` to render a continuation row directly under a
* previously-titled one (used by Rediscover when it has both album and
@@ -24,10 +24,7 @@ import kotlinx.coroutines.launch
/**
* Always-visible circular play button overlaid on Home tile cover art
* (AlbumCard / ArtistCard / PlaylistCard). Mirrors
* `flutter_client/lib/library/widgets/play_circle_button.dart`: 44dp
* accent-colored disc, parchment Play icon, drop shadow, self-managed
* loading spinner.
* (AlbumCard / ArtistCard / PlaylistCard).
*
* [onPlay] is a suspend lambda so the caller can await the fetch-and-
* queue setup. While it runs, the icon is swapped for a spinner and
@@ -54,8 +54,6 @@ val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0)
* its own Scaffold's snackbar host as well; this one is the shell-
* wide catch-all for events that outlive a single screen).
*
* Mirrors the Flutter `_ShellWithPlayerBar` (lib/shared/routing.dart).
*
* Top-level routes (Home / Library / Search / Discover / Playlists /
* Settings / Admin / detail screens) wrap themselves with this.
* Full-screen routes (NowPlaying / Queue / ServerUrl / Login)
@@ -8,7 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
/**
* The Flutter theme's `ColorScheme` role mapping (theme_data.dart):
* The `ColorScheme` role mapping:
* primary = accent (forest-teal) — same both modes
* onPrimary = onAction
* surface = iron
+5 -3
View File
@@ -10,9 +10,11 @@ package api
// pgtype.Timestamptz / Date into strings, mirroring the pattern
// playlistRowView already established for /api/playlists.
//
// Field names match flutter_client/lib/cache/sync_controller.dart's
// _*FromJson helpers exactly. Adding a field server-side requires a
// matching read in the Flutter helper or it'll be silently dropped.
// Field names match what the Android client deserialises in
// models/wire/SyncResponseWire.kt (Sync{Artist,Album,Track}Wire) exactly.
// Adding a field server-side requires a matching @SerialName there or it is
// silently dropped — kotlinx.serialization ignores unknown keys, so the
// failure is a missing value at runtime, not an error at parse time.
import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+5 -3
View File
@@ -11,9 +11,11 @@ import (
// These tests pin the wire-format keys for /api/library/sync upserts.
// Without them, sqlc model field-name drift or accidental
// `json.Marshal(rawStruct)` regressions would silently break the
// Flutter client (which reads snake_case keys via _*FromJson helpers
// in flutter_client/lib/cache/sync_controller.dart).
// `json.Marshal(rawStruct)` regressions would silently break the Android
// client, which reads these snake_case keys via @SerialName in
// models/wire/SyncResponseWire.kt. "Silently" is the operative word:
// kotlinx.serialization skips unknown keys, so a renamed field arrives as
// a default value rather than an error.
// validUUID is a deterministic test UUID — not a real value, just
// something that pgtype.UUID.Valid will accept.