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