docs: Android comments say what the app does, not how it came to be (#6277)
release / govulncheck (push) Successful in 23s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m42s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 6m2s
release / Build signed APK (releases and dev) (push) Successful in 6m15s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped

Every flagged comment block in the Android app, its tests and its XML/TOML
config, rewritten with the cited issue in view: the constraint is kept, the
story behind it dropped, and issue, milestone, rule, phase and drift ids,
dates and operator quotes removed. "Mirrors the Flutter client's X"
pointers are gone; that client is no longer in the repo.

Comments that no longer matched the code, corrected:
- SyncController: a 410 resets the cursor and re-pulls; it never dropped
  the three local caches.
- PlayerFactory, PlayerModule, AudioCacheIndexEntity, CacheConfig,
  CacheSource: there is no eviction worker and no liked-track bucket;
  SimpleCache evicts by plain LRU over the rolling cap, and the liked cap
  is stored but unenforced.
- CacheSettings, StorageCard: the prefetcher exists (AudioPrefetcher);
  pin-on-like still does not.
- LibraryApi, LibraryRepository, BrowseViewModel: the cache now carries
  the missing-file mark; the browse indexes stay server-backed for one
  source of grouping.
- OutputRoute: UPnP routes exist; only CAST and SONOS are reserved.
- PlayerFactory, RemotePlayerState, OutputPickerController: a failed
  transport command does not drop the UPnP route.
- LibraryScreen: History, Liked and Hidden are real tabs, not placeholders.
- AVTransportClient: the extend loop lives in SonosQueueLoader.
- "the operator" meaning the listener now says "the user".

Verified with a Kotlin lexer comparing every file's non-comment tokens
against HEAD: 394 files, 0 differences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 12:59:07 -04:00
co-authored by Claude Opus 5.5
parent 9f588b6d61
commit ae7e05c579
228 changed files with 844 additions and 1088 deletions
+9 -14
View File
@@ -1,9 +1,9 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
// kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default, // kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default,
// and KSP 2.3.x supports it (PR #2674, merged Oct 2025). serialization // and KSP 2.3.x supports it. serialization + compose-compiler are
// + compose-compiler are language-level Kotlin compiler plugins and // language-level Kotlin compiler plugins and still need explicit
// still need explicit application. // application.
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
@@ -31,14 +31,9 @@ android {
// //
// versionCode is minutes since 2020-01-01 at BUILD time. It is the // versionCode is minutes since 2020-01-01 at BUILD time. It is the
// value the platform decides installs by, so it must be monotonic by // value the platform decides installs by, so it must be monotonic by
// construction. // construction. Not a commit count: that runs ahead on `dev`, so a dev
// // build would outrank the `main` release meant to replace it and
// This comment used to say versionCode was a commit count and that it // Android would refuse the install as a downgrade.
// was "monotonic forever". It was neither — a commit count runs ahead
// on `dev`, so a dev build outranked the `main` release meant to
// replace it and Android refused the install as a downgrade. Worth
// knowing the claim was here, stated as a reassurance, while the bug
// it denied was live.
val versionNameOverride = val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } (project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride = val versionCodeOverride =
@@ -73,9 +68,9 @@ android {
"proguard-rules.pro", "proguard-rules.pro",
) )
// Signed with the release key or not at all. Falling back to the // Signed with the release key or not at all. Falling back to the
// debug key made a missing secret into a published APK that no // debug key would turn a missing secret into a published APK that no
// install could ever update (family idea #5103, practice 2). An // install could ever update. An unsigned build installs nowhere, so
// unsigned build installs nowhere, so the gap shows at once. // the gap shows at once.
signingConfig = signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) { if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
null null
+3 -6
View File
@@ -8,7 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Notifications when the app is closed (M489 #5347): the delivery <!-- Notifications when the app is closed: the delivery
service, and starting it again after a reboot or an update. --> service, and starting it again after a reboot or an update. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -84,11 +84,8 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- The FileProvider that used to live here existed solely to expose the <!-- No FileProvider: the self-update streams the downloaded APK into a
downloaded update APK as a content:// URI for the old ACTION_VIEW PackageInstaller session, so nothing needs a content:// URI. -->
install intent. A PackageInstaller session takes a stream instead,
so both the provider and res/xml/file_paths.xml are gone — nothing
else in the app ever used that authority. -->
<!-- On-demand WorkManager initialization: MinstrelApplication <!-- On-demand WorkManager initialization: MinstrelApplication
implements Configuration.Provider and supplies the implements Configuration.Provider and supplies the
@@ -106,7 +106,7 @@ class MainActivity : ComponentActivity() {
} }
/** /**
* Android 13+ asks before an app may post notifications (M489 #5347). * Android 13+ asks before an app may post notifications.
* Asked once delivery is wanted (signed in, background delivery on), * Asked once delivery is wanted (signed in, background delivery on),
* each launch until answered: after a second "no" the system stops * each launch until answered: after a second "no" the system stops
* showing the prompt by itself, and Settings → Notifications links to * showing the prompt by itself, and Settings → Notifications links to
@@ -79,7 +79,7 @@ class MinstrelApplication :
/** /**
* Same construct-the-singleton trick — DeliveryLauncher starts and stops * Same construct-the-singleton trick — DeliveryLauncher starts and stops
* the background-delivery service and runs the notification catch-up on * the background-delivery service and runs the notification catch-up on
* every nudge and reconnect (M489 #5347). Without this @Inject no phone * every nudge and reconnect. Without this @Inject no phone
* notification would ever be posted. * notification would ever be posted.
*/ */
@Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher @Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher
@@ -171,7 +171,7 @@ class MinstrelApplication :
*/ */
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher @Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
// Device diagnostics (M9). The reporter gates itself on the account's // Device diagnostics. The reporter gates itself on the account's
// debug flag; the uploader drains its buffer on a tick / recovery. // debug flag; the uploader drains its buffer on a tick / recovery.
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter @Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader @Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
@@ -181,7 +181,7 @@ class MinstrelApplication :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Debug builds get the full DebugTree (verbose). Release builds // Debug builds get the full DebugTree (verbose). Release builds
// get a WARN+ tree so operator-driven diagnosis via `adb logcat` // get a WARN+ tree so diagnosis via `adb logcat`
// still surfaces UPnP / cast failures, OkHttp errors, and our // still surfaces UPnP / cast failures, OkHttp errors, and our
// own Timber.w / Timber.e calls — without the chatty DEBUG / // own Timber.w / Timber.e calls — without the chatty DEBUG /
// INFO traffic flooding the buffer in production. // INFO traffic flooding the buffer in production.
@@ -229,10 +229,9 @@ class MinstrelApplication :
* before Coil makes its first request. * before Coil makes its first request.
* *
* Crossfade is set here rather than per-call so every cover surface * Crossfade is set here rather than per-call so every cover surface
* in the app fades its artwork in instead of snapping it. Art * in the app fades its artwork in instead of snapping it, and
* landing a beat after its tile was the most visible pop-in on Home * `ServerImage` fades its placeholder out over the same window so the
* (issue #2327); `ServerImage` fades its placeholder out over the * two read as one cross-dissolve.
* same window so the two read as one cross-dissolve.
*/ */
override fun newImageLoader(context: android.content.Context): ImageLoader = override fun newImageLoader(context: android.content.Context): ImageLoader =
ImageLoader.Builder(context) ImageLoader.Builder(context)
@@ -11,10 +11,9 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Read-through accessor for the admin quarantine queue. Mirrors the * Read-through accessor for the admin quarantine queue. No Room cache —
* Flutter `AdminQuarantineController`. No Room cache — same rationale * same rationale as AdminRequests. Three resolution actions all delete
* as AdminRequests. Three resolution actions all delete the row from * the row from the queue once the server accepts them.
* the queue once the server accepts them.
*/ */
@Singleton @Singleton
class AdminQuarantineRepository @Inject constructor( class AdminQuarantineRepository @Inject constructor(
@@ -14,7 +14,7 @@ import javax.inject.Singleton
* *
* 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
* (no mutation queue) because operator confirmation is point-and-shoot; * (no mutation queue) because admin confirmation is point-and-shoot;
* if the request fails the UI surfaces the error and rolls back. * if the request fails the UI surfaces the error and rolls back.
*/ */
@Singleton @Singleton
@@ -12,7 +12,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Read-through accessor for the tag-enrichment provider settings (#1521). * Read-through accessor for the tag-enrichment provider settings.
* No Room caching — admin settings are infrequent point-and-shoot edits; * No Room caching — admin settings are infrequent point-and-shoot edits;
* mutations fire direct REST and the ViewModel reconciles on failure. * mutations fire direct REST and the ViewModel reconciles on failure.
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy). * Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
@@ -13,8 +13,8 @@ import javax.inject.Singleton
/** /**
* Read-through accessor for the admin users list + the four per-user * Read-through accessor for the admin users list + the four per-user
* mutations. Mirrors Flutter's `AdminUsersController`. Same * mutations. Same no-Room-cache, no-MutationQueue pattern as the other
* no-Room-cache, no-MutationQueue pattern as the other admin slices. * admin repositories.
*/ */
@Singleton @Singleton
class AdminUsersRepository @Inject constructor( class AdminUsersRepository @Inject constructor(
@@ -39,7 +39,7 @@ class AdminInvitesViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): reload a failed list // Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull. // when server health returns instead of waiting for a manual pull.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
@@ -40,8 +40,8 @@ class AdminQuarantineViewModel @Inject constructor(
/** /**
* One-shot messages for the screen's snackbar. A failed action has to say * One-shot messages for the screen's snackbar. A failed action has to say
* why: the row quietly reappearing reads as a glitch, and for a Delete * why: the row quietly reappearing reads as a glitch, and for a Delete
* file refused by a read-only library it hides the one thing the * file refused by a read-only library it hides the one thing an
* operator can fix (#3918). * admin can fix.
*/ */
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED) private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow() val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
@@ -53,7 +53,7 @@ class AdminQuarantineViewModel @Inject constructor(
.filter { it.kind.startsWith("quarantine.") } .filter { it.kind.startsWith("quarantine.") }
.collect { refresh() } .collect { refresh() }
} }
// Screen-level auto-recovery (issue #1245): reload a failed list // Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull. // when server health returns instead of waiting for a manual pull.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
@@ -43,7 +43,7 @@ class AdminRequestsViewModel @Inject constructor(
.filter { it.kind in RELEVANT_EVENT_KINDS } .filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() } .collect { refresh() }
} }
// Screen-level auto-recovery (issue #1245): reload a failed list // Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull. // when server health returns instead of waiting for a manual pull.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
@@ -38,7 +38,7 @@ class AdminTagSourcesViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery: reload a failed list when server // Screen-level auto-recovery: reload a failed list when server
// health returns instead of waiting for a manual pull (issue #1245). // health returns instead of waiting for a manual pull.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
if (internal.value is AdminTagSourcesUiState.Error) refresh() if (internal.value is AdminTagSourcesUiState.Error) refresh()
@@ -33,7 +33,7 @@ class AdminUsersViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): reload a failed list // Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull. // when server health returns instead of waiting for a manual pull.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
@@ -12,18 +12,12 @@ import javax.inject.Singleton
* flow), and clears the store on 401 so downstream code can react * flow), and clears the store on 401 so downstream code can react
* to logout. * to logout.
* *
* Mirrors the Flutter Dio interceptor pattern.
*
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host * Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
* the same way BaseUrlInterceptor is. Drift #568 / #569 caught two * the same way BaseUrlInterceptor is. The shared OkHttpClient also
* leaks here: (1) the session cookie was being attached to every * serves Coil's image fetches to external hosts such as
* external request the shared OkHttpClient services — including * artwork.musicbrainz.org and coverartarchive.org; unscoped, this
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org * would hand the session identifier to third-party logging, and a 401
* — exposing the session * from any of those hosts would sign the user out of Minstrel.
* identifier to third-party logging; (2) a 401 from any of those
* external hosts silently wiped the user's Minstrel session.
* Restricting both attach + clear to placeholder-host requests
* closes both gaps.
*/ */
@Singleton @Singleton
class AuthCookieInterceptor @Inject constructor( class AuthCookieInterceptor @Inject constructor(
@@ -17,15 +17,14 @@ import javax.inject.Singleton
* *
* This interceptor closes the gap: Retrofit can stay built with the * This interceptor closes the gap: Retrofit can stay built with the
* placeholder forever, and every actual Minstrel-bound request gets * placeholder forever, and every actual Minstrel-bound request gets
* retargeted at the live AuthStore value. Lets the user change * retargeted at the live AuthStore value, so the user can change the
* server URL in Settings without an app relaunch (Phase 11 wiring). * server URL in Settings without an app relaunch.
* *
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are * Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
* rewritten. Absolute external URLs — artwork from * rewritten. Absolute external URLs — artwork from
* artwork.musicbrainz.org / coverartarchive.org, for example — must * artwork.musicbrainz.org / coverartarchive.org, for example — must
* reach their authored host unchanged; rewriting them to the * reach their authored host unchanged: rewritten to the Minstrel host
* Minstrel host produced 404s the user saw as missing Discover * they 404, and Discover suggestions show no covers.
* suggestion covers.
* *
* No-op when the stored base URL is unparseable (falls through to * No-op when the stored base URL is unparseable (falls through to
* whatever Retrofit had) — that case shows up as a transport-level * whatever Retrofit had) — that case shows up as a transport-level
@@ -16,11 +16,10 @@ object MinstrelServerRequest
/** /**
* Plain `http://` to the Minstrel server is allowed only when the connection * Plain `http://` to the Minstrel server is allowed only when the connection
* actually lands on a private address (family security baseline #5105, * actually lands on a private address.
* practice 13).
* *
* Cleartext stays permitted app-wide for LAN servers and UPnP * Cleartext stays permitted app-wide for LAN servers and UPnP
* (network_security_config.xml, #2439), but a password or session cookie sent * (network_security_config.xml), but a password or session cookie sent
* over plain HTTP to a public address can be read by anyone on the path. * over plain HTTP to a public address can be read by anyone on the path.
* *
* Checked per connection, on the address the socket really reached, not on * Checked per connection, on the address the socket really reached, not on
@@ -44,9 +44,9 @@ object ErrorCopy {
} }
/** /**
* Codes whose server message carries specifics the operator needs in * Codes whose server message carries specifics an admin needs in
* order to act — which directory, which uid — that fixed copy cannot say. * order to act — which directory, which uid — that fixed copy cannot say.
* For these the message follows the copy (#3918). Kept to a named set on * For these the message follows the copy. Kept to a named set on
* purpose: most server messages are internal detail. Mirrors web's * purpose: most server messages are internal detail. Mirrors web's
* errors.ts. * errors.ts.
*/ */
@@ -57,16 +57,15 @@ object NetworkModule {
.addInterceptor(reachability) .addInterceptor(reachability)
// AuthCookieInterceptor MUST run before BaseUrlInterceptor. // AuthCookieInterceptor MUST run before BaseUrlInterceptor.
// Both scope on `host == PLACEHOLDER_HOST` to distinguish // Both scope on `host == PLACEHOLDER_HOST` to distinguish
// Minstrel-server requests from external image fetches // Minstrel-server requests from external image fetches. If
// (drift #568 / #569). If BaseUrlInterceptor runs first it // BaseUrlInterceptor runs first it rewrites the host to the real
// rewrites the host to the real server before auth sees the // server before auth sees the request, auth's placeholder check
// request, auth's placeholder check fails, and the session // fails, and the session cookie is neither attached on outgoing
// cookie is neither attached on outgoing requests nor // requests nor captured from Set-Cookie on login — fresh installs
// captured from Set-Cookie on login — fresh installs get // get stuck at the Welcome screen. Auth first means it sees
// stuck at the Welcome screen. Auth first means it sees
// placeholder.invalid, attaches/captures correctly, then // placeholder.invalid, attaches/captures correctly, then
// BaseUrlInterceptor retargets to the live AuthStore host // BaseUrlInterceptor retargets to the live AuthStore host for
// for transport. // transport.
.addInterceptor(auth) .addInterceptor(auth)
.addInterceptor(baseUrl) .addInterceptor(baseUrl)
.addInterceptor(logging) .addInterceptor(logging)
@@ -27,9 +27,8 @@ interface AdminInvitesApi {
} }
/** /**
* Body for `POST /api/admin/invites`. The Flutter client omits the * Body for `POST /api/admin/invites`. An empty note is sent as null,
* field when note is empty; Kotlin/serialization just serializes * which the server treats the same as an absent field.
* null which the server treats identically.
*/ */
@Serializable @Serializable
data class CreateInviteBody(val note: String? = null) data class CreateInviteBody(val note: String? = null)
@@ -12,9 +12,9 @@ import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
/** /**
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin * Retrofit interface for `/api/admin/tag-sources` — the admin surface
* surface for the tag-enrichment provider settings (#1490). Mirrors the * for the tag-enrichment provider settings. Mirrors the web
* web integrations "Tag enrichment sources" card. Same shape as the * integrations "Tag enrichment sources" card. Same shape as the
* cover-sources admin surface; kept independent so a new tag source is * cover-sources admin surface; kept independent so a new tag source is
* added without touching art settings. * added without touching art settings.
*/ */
@@ -30,7 +30,7 @@ interface CastApi {
* the 21_600 default (6h) is long enough to play through any typical * the 21_600 default (6h) is long enough to play through any typical
* track without re-minting mid-playback. * track without re-minting mid-playback.
* *
* [level] asks for the leveled stream (M464 #5001): the track rendered at * [level] asks for the leveled stream: the track rendered at
* the user's loudness gain, which the server works out from their setting. * the user's loudness gain, which the server works out from their setting.
* [asAlbum] says the track plays among its album in order, which picks * [asAlbum] says the track plays among its album in order, which picks
* album gain in auto mode. [prerender] says the speaker will fetch it * album gain in auto mode. [prerender] says the speaker will fetch it
@@ -7,7 +7,7 @@ import retrofit2.http.Body
import retrofit2.http.POST import retrofit2.http.POST
/** /**
* Retrofit interface for device diagnostics ingest (M9). One call * Retrofit interface for device diagnostics ingest. One call
* uploads a batch of buffered events. The server stores them only when * uploads a batch of buffered events. The server stores them only when
* the account's debug_mode_enabled flag is on (it returns 204 otherwise, * the account's debug_mode_enabled flag is on (it returns 204 otherwise,
* which the uploader treats as success so it can drop the batch). * which the uploader treats as success so it can drop the batch).
@@ -19,8 +19,7 @@ import retrofit2.http.Query
* second, so the Discover screen searches when the query is submitted, * second, so the Discover screen searches when the query is submitted,
* not on every keystroke. * not on every keystroke.
* *
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10 * Cancel + my-requests-list endpoints live on RequestsApi, not here.
* Commit B), not here.
*/ */
interface DiscoverApi { interface DiscoverApi {
@GET("api/discover/suggestions") @GET("api/discover/suggestions")
@@ -56,16 +56,15 @@ interface LibraryApi {
@GET("api/library/shuffle") @GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire> suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
// Browse axes (#367). Both indexes are unpaged by design: the client needs // Browse axes. Both indexes are unpaged by design: the client needs
// the whole set to render a browsable picker, and even a messy library // the whole set to render a browsable picker, and even a messy library
// yields hundreds of rows, not thousands. // yields hundreds of rows, not thousands.
// //
// These read the server rather than the local cache on purpose. The cache // These read the server rather than the local cache on purpose: the
// is a full mirror of the library, but /api/library/sync ships tracks whose // server's index leaves out tracks whose files are missing and is the
// files are missing and carries no flag for it (#2704), while the browse // one the web client shows, so the two clients agree. The cache carries
// index filters them out -- so a locally-computed index would disagree with // the same mark (cached_tracks.missing), but an index built from it
// the server's and with the web client. One source of truth wins over // would be a second copy of the server's grouping that could drift.
// offline capability here until #2704 is resolved.
@GET("api/library/genres") @GET("api/library/genres")
suspend fun getGenres(): List<GenreCountWire> suspend fun getGenres(): List<GenreCountWire>
@@ -92,9 +92,7 @@ data class ChangePasswordRequest(
/** /**
* Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled` * Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled`
* is set; the server treats other fields as untouched. Mirrors * is set; the server treats other fields as untouched.
* Flutter's `setListenBrainzToken` / `setListenBrainzEnabled` split
* over a single endpoint shape.
*/ */
@Serializable @Serializable
data class ListenBrainzPutBody( data class ListenBrainzPutBody(
@@ -10,7 +10,7 @@ import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
/** /**
* The notifications inbox and its per-user settings (M489). The server * The notifications inbox and its per-user settings. The server
* renders each notice's title, body and link, so the app shows them as given. * renders each notice's title, body and link, so the app shows them as given.
*/ */
interface NotificationsApi { interface NotificationsApi {
@@ -49,7 +49,7 @@ interface PlaylistsApi {
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse
/** /**
* GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2). Returns * GET /api/playlists/system/{kind}/shuffle. Returns
* 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
@@ -60,8 +60,8 @@ interface PlaylistsApi {
} }
/** /**
* POST /api/playlists/{id}/tracks body. Server expects snake_case * POST /api/playlists/{id}/tracks body. The server expects snake_case
* `track_ids` per the Flutter wire shape. * `track_ids`.
*/ */
@Serializable @Serializable
data class AppendTracksRequest( data class AppendTracksRequest(
@@ -13,7 +13,7 @@ import retrofit2.http.Path
* *
* 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
* `/api/admin/quarantine` route (Phase 10 Commit D). * `/api/admin/quarantine` route.
*/ */
interface QuarantineApi { interface QuarantineApi {
@GET("api/quarantine/mine") @GET("api/quarantine/mine")
@@ -4,7 +4,7 @@ import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Query import retrofit2.http.Query
/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */ /** The player's loudness lookup, kept apart from the browse surface in [LibraryApi]. */
interface ReplayGainApi { interface ReplayGainApi {
/** /**
* ReplayGain values for up to 200 comma-separated track ids. An id * ReplayGain values for up to 200 comma-separated track ids. An id
@@ -10,7 +10,7 @@ import retrofit2.http.Path
* *
* 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
* a separate `/api/admin/requests` route (Phase 10 Commit D). * a separate `/api/admin/requests` route.
* *
* The DELETE endpoint returns the cancelled row (not 204) so the UI * The DELETE endpoint returns the cancelled row (not 204) so the UI
* can patch local state without a refetch. * can patch local state without a refetch.
@@ -6,7 +6,7 @@ import retrofit2.http.GET
import retrofit2.http.Query import retrofit2.http.Query
/** /**
* Retrofit interface for `/api/library/sync` (#357). The endpoint * Retrofit interface for `/api/library/sync`. The endpoint
* returns three possible status codes that the SyncController * returns three possible status codes that the SyncController
* branches on: * branches on:
* - 200: parsed body with cursor + upserts + deletes * - 200: parsed body with cursor + upserts + deletes
@@ -57,7 +57,7 @@ class AuthController @Inject constructor(
} }
} }
// Refresh from /api/me on startup so a remotely-changed account // Refresh from /api/me on startup so a remotely-changed account
// flag (e.g. admin enabling debug mode, M9) reaches the device // flag (e.g. an admin enabling debug mode) reaches the device
// without a re-login. Best-effort; failures keep the cached value. // without a re-login. Best-effort; failures keep the cached value.
scope.launch { refreshProfile() } scope.launch { refreshProfile() }
} }
@@ -35,13 +35,13 @@ import javax.inject.Singleton
* sees the new value immediately; the DAO write coroutine catches up * sees the new value immediately; the DAO write coroutine catches up
* shortly after. * shortly after.
* *
* **The session cookie is the exception** (M462 #4985): it is persisted * **The session cookie is the exception**: it is persisted through
* through [SessionVault] (Keystore-encrypted), not the Room row. On the * [SessionVault] (Keystore-encrypted), not the Room row. A cookie still
* first launch after the upgrade, a cookie still in the row is moved into * in the row is moved into the vault on launch and the column cleared,
* the vault and the column cleared, so nobody is signed out by the change. * so nobody is signed out by the move. If the Keystore cannot be used
* If the Keystore cannot be used on a device, the cookie stays in the row * on a device, the cookie stays in the row rather than being lost.
* as before rather than being lost. [awaitSessionHydrated] lets a caller * [awaitSessionHydrated] lets a caller that needs a definitive answer
* that needs a definitive answer (the auth gate) wait for this. * (the auth gate) wait for this.
*/ */
// AuthStore is the single-row facade over auth_session (de-facto // AuthStore is the single-row facade over auth_session (de-facto
// app_preferences — see entity comment). It legitimately owns one // app_preferences — see entity comment). It legitimately owns one
@@ -81,7 +81,7 @@ class AuthStore @Inject constructor(
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT) private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow() val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
// Background delivery (M489 #5347): the device's choice, on by default. // Background delivery: the device's choice, on by default.
// The shade's high-water mark lives in the same row but is read and // The shade's high-water mark lives in the same row but is read and
// written through the DAO by NotificationSync, awaited, never cached here. // written through the DAO by NotificationSync, awaited, never cached here.
private val backgroundDeliveryState = MutableStateFlow(true) private val backgroundDeliveryState = MutableStateFlow(true)
@@ -131,8 +131,8 @@ class AuthStore @Inject constructor(
/** /**
* Suspends until the stored session cookie has been loaded into * Suspends until the stored session cookie has been loaded into
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged * [sessionCookie], or [HYDRATION_DEADLINE_MS] passes, so a wedged
* Keystore must not leave the start screen spinning). Returns false on * Keystore cannot leave the start screen spinning. Returns false on
* the deadline; the caller then decides from whatever has loaded, and a * the deadline; the caller then decides from whatever has loaded, and a
* late hydration still lands in [sessionCookie]. * late hydration still lands in [sessionCookie].
*/ */
@@ -19,15 +19,14 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Where the session cookie lives at rest (M462 #4985). * Where the session cookie lives at rest.
* *
* The cookie is a bearer credential: anyone holding it is signed in as the * The cookie is a bearer credential: anyone holding it is signed in as the
* user until the server expires or revokes it. It used to sit in plain text * user until the server expires or revokes it. In plain text it would be
* in the Room `auth_session` row, readable from any copy of the app's data * readable from any copy of the app's data directory (a rooted device, an
* directory (a rooted device, an adb backup of a debuggable build, a * adb backup of a debuggable build, a forensic image). Only ciphertext is
* forensic image). Now only ciphertext is stored, under an AES key that * stored, under an AES key that lives in the Android Keystore and never
* lives in the Android Keystore and never leaves it, so a copy of the * leaves it, so a copy of the files alone yields nothing usable.
* files alone yields nothing usable.
*/ */
interface SessionVault { interface SessionVault {
/** The stored cookie, or null when none is stored or it can't be decrypted. */ /** The stored cookie, or null when none is stored or it can't be decrypted. */
@@ -19,8 +19,8 @@ import javax.inject.Inject
* persisted auth state. Reads the row from AuthSessionDao directly, and * persisted auth state. Reads the row from AuthSessionDao directly, and
* the session cookie only after [AuthStore.awaitSessionHydrated]: both * the session cookie only after [AuthStore.awaitSessionHydrated]: both
* StateFlows default to null until their async load lands, and we need a * StateFlows default to null until their async load lands, and we need a
* definitive answer before drawing any nav graph. The cookie is no longer * definitive answer before drawing any nav graph. The cookie lives in the
* in the row (it lives in the Keystore-backed SessionVault, #4985). * Keystore-backed SessionVault, not the row.
* *
* - no row at all → ServerUrl (first launch) * - no row at all → ServerUrl (first launch)
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in) * - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
@@ -18,15 +18,13 @@ import javax.inject.Singleton
* Populates `audio_cache_index` from playback so the offline pools * Populates `audio_cache_index` from playback so the offline pools
* ([ShuffleSource]) and the cached indicator have a data source. * ([ShuffleSource]) and the cached indicator have a data source.
* *
* Mirrors the user-visible behavior of Flutter's `AudioCacheManager` * A track you play gets cached as a side effect of streaming, and
* (`registerStreamCache` + `touch`): a track you play gets cached as a * playing it bumps its recency. Media3's `SimpleCache` is the real byte
* side effect of streaming, and playing it bumps its recency. The * store (span-based, keyed by trackId via the MediaItem's custom cache
* Android idiom differs intentionally — Media3's `SimpleCache` is the * key), so this index is a lightweight *play-recency* record
* real byte store (span-based, keyed by trackId via the MediaItem's * (source = INCIDENTAL), not a file-per-track table. SimpleCache stays
* custom cache key), so this index is a lightweight *play-recency* * the source of truth for "is it actually on disk"; this index orders
* record (source = INCIDENTAL) rather than Flutter's file-per-track * "recently played" for the offline pools.
* table. SimpleCache stays the source of truth for "is it actually on
* disk"; this index orders "recently played" for the offline pools.
* *
* State observed against [PlayerController.uiState], deduped by track * State observed against [PlayerController.uiState], deduped by track
* id so each entry into the player records once. Eager-constructed via * id so each entry into the player records once. Eager-constructed via
@@ -19,11 +19,10 @@ private const val POOL_LIMIT = 100
* storage/eviction-only and never filters playback — exactly what * storage/eviction-only and never filters playback — exactly what
* this relies on. * this relies on.
* *
* Divergence note: Flutter's `audioCacheIndex` only holds fully-cached * The index records plays (see `CacheIndexer`), not residency, so the
* tracks, so its pools are inherently resident. Android's index records * recency list is intersected with true Media3 SimpleCache residency
* plays (see `CacheIndexer`), so the recency list is intersected with * here — the pool never offers a played-then-evicted track that would
* true Media3 SimpleCache residency here — the pool never offers a * fail to play offline.
* played-then-evicted track that would fail to play offline.
* *
* Surfaced on Home's Playlists row only when offline; tap shuffles + * Surfaced on Home's Playlists row only when offline; tap shuffles +
* plays the pool from the cache. * plays the pool from the cache.
@@ -58,7 +57,7 @@ class ShuffleSource @Inject constructor(
if (orderedIds.isEmpty()) return emptyList() if (orderedIds.isEmpty()) return emptyList()
val byId = trackDao.getByIds(orderedIds).associateBy { it.id } val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
return orderedIds.mapNotNull { id -> return orderedIds.mapNotNull { id ->
// Clear the server's missing mark (#2704). Every id reaching here // Clear the server's missing mark. Every id reaching here
// came through residentIdsByRecency, which already proved the // came through residentIdsByRecency, which already proved the
// AUDIO is in the local cache — so these play regardless of what // AUDIO is in the local cache — so these play regardless of what
// the server has lost, and the queue filter in PlayerController // the server has lost, and the queue filter in PlayerController
@@ -1,16 +1,16 @@
package com.fabledsword.minstrel.cache.audiocache package com.fabledsword.minstrel.cache.audiocache
/** /**
* Defaults for the 2-bucket audio cache. * Defaults for the audio cache; the user's Storage settings override them.
* *
* - `likedCapBytes`: cap for the protected bucket — cached files for * - `likedCapBytes`: cap meant for a protected bucket of liked tracks'
* tracks the user has liked. Evicted only after the rolling bucket * files. Nothing enforces it yet; SimpleCache evicts by the rolling
* is fully drained (almost never under normal use). * cap alone.
* - `rollingCapBytes`: cap for the unprotected bucket — incidental / * - `rollingCapBytes`: cap for incidental / auto-prefetch downloads,
* auto-prefetch downloads. LRU within this bucket. * evicted least-recently-used first.
* *
* Phase 11 (Settings) will allow user overrides; the values are kept * The values are generous so first-time installs work well without
* generous so first-time installs work well without configuration. * configuration.
*/ */
data class CacheConfig( data class CacheConfig(
val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES, val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES,
@@ -10,18 +10,18 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
* 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
* unlimited. Effective at next app start (SimpleCache is built once * unlimited. Nothing enforces it yet: SimpleCache evicts by
* per process). * [rollingCapBytes] alone, and the Storage card hides this control.
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads. * - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
* Same 0=unlimited / restart-to-apply caveat. * 0 means unlimited. Effective at next app start (SimpleCache is
* - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher * built once per process).
* pre-downloads. Has no effect until the prefetcher lands (separate * - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
* audit follow-up) — persisted now so the user can pre-configure. * pre-downloads.
* - [cacheLikedTracks]: when true, liking a track should also pin its * - [cacheLikedTracks]: when true, liking a track should also pin its
* audio to the liked-cache bucket. Has no effect until pin-on-like * audio to the liked-cache bucket. Nothing reads it yet, and the
* lands (separate follow-up). * Storage card hides this control.
* *
* Defaults match Flutter: 5 GiB per bucket, prefetch=5, cache-liked=true. * Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
*/ */
@Serializable @Serializable
data class CacheSettings( data class CacheSettings(
@@ -40,17 +40,12 @@ import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
/** /**
* Single Room database for the whole app. Mirrors the Flutter client's * Single Room database for the whole app, one entity family per @Entity.
* Drift `AppDb` — same logical schema, one entity-family per @Entity.
* *
* Native v1 starts at schema version 1 (the Flutter Drift schemaVersion 11 * The server is the source of truth: the sync controller refills the
* is an internal client detail; the server is the source of truth and the * cache on first launch. Schema bumps land with explicit `Migration`
* sync controller refills on first launch). Future schema bumps land per * entries; `fallbackToDestructiveMigration` in `DatabaseModule` rebuilds
* change with explicit `Migration` entries; `fallbackToDestructiveMigration` * the database from a version that has no migration path.
* in `DatabaseModule` is the safety net while we're pre-v1.
*
* Entity families land in Task 4.2 slices. The set will grow until the
* whole Drift schema is mirrored.
*/ */
@Database( @Database(
entities = [ entities = [
@@ -73,25 +68,25 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedNotificationSettingsEntity::class, CachedNotificationSettingsEntity::class,
], ],
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's // v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
// background-delivery choice and its shade high-water mark (M489 #5347). // background-delivery choice and its shade high-water mark.
// v11: + cached_notifications and cached_notification_settings, the // v11: + cached_notifications and cached_notification_settings, the
// notifications inbox and its settings (M489). MIGRATION_10_11 creates // notifications inbox and its settings. MIGRATION_10_11 creates
// both; nothing to backfill, the first refresh fills them. // both; nothing to backfill, the first refresh fills them.
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/ // v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
// albumPeak, the ReplayGain values the player levels by (M464 #5000). // albumPeak, the ReplayGain values the player levels by.
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends // MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
// every row and an existing cache gains its values. // every row and an existing cache gains its values.
// v9: + auth_session.normalizationJson, the loudness-normalization // v9: + auth_session.normalizationJson, the loudness-normalization
// preference (M464 #4998). The first schema step with an explicit // preference. The first schema step with an explicit Migration
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this // (MIGRATION_8_9): a destructive rebuild would also wipe this row —
// row — the server address and theme — and the queued offline writes, // the server address and theme — and the queued offline writes,
// which is too much to lose for one added column. // which is too much to lose for one added column.
// v8: + cached_tracks.missing, the server's missing-file mark (#2704), // v8: + cached_tracks.missing, the server's missing-file mark,
// so cache-first surfaces stop offering files that cannot stream. // so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column // v7: + diagnostic_events table and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch — // on auth_session. Versions before v8 rebuild destructively, which is
// which is exactly right here: the next sync refills every row with the // right for them: the next sync refills every row with the new
// new column populated, so there is nothing to migrate by hand. // columns populated, so there is nothing to migrate by hand.
version = 12, version = 12,
exportSchema = true, exportSchema = true,
) )
@@ -115,7 +110,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedNotificationDao(): CachedNotificationDao abstract fun cachedNotificationDao(): CachedNotificationDao
} }
/** v8 → v9: add the nullable normalization preference column (#4998). */ /** v8 → v9: add the nullable normalization preference column. */
val MIGRATION_8_9: Migration = object : Migration(8, 9) { val MIGRATION_8_9: Migration = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT") db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
@@ -123,7 +118,7 @@ val MIGRATION_8_9: Migration = object : Migration(8, 9) {
} }
/** /**
* v9 → v10: the gain columns (#5000). Rows synced before this carry no gains, * v9 → v10: the gain columns. Rows synced before this carry no gains,
* and the sync is incremental, so it would never re-send them: the cursor goes * and the sync is incremental, so it would never re-send them: the cursor goes
* back to 0 and the next sync is a full one, upserting every row in place. * back to 0 and the next sync is a full one, upserting every row in place.
*/ */
@@ -138,7 +133,7 @@ val MIGRATION_9_10: Migration = object : Migration(9, 10) {
} }
/** /**
* v10 → v11: the notifications inbox cache and its settings (M489). The SQL * v10 → v11: the notifications inbox cache and its settings. The SQL
* matches what Room generates for the two entities; Room checks it on open. * matches what Room generates for the two entities; Room checks it on open.
*/ */
val MIGRATION_10_11: Migration = object : Migration(10, 11) { val MIGRATION_10_11: Migration = object : Migration(10, 11) {
@@ -157,7 +152,7 @@ val MIGRATION_10_11: Migration = object : Migration(10, 11) {
} }
/** /**
* v11 → v12: background delivery (M489 #5347). The device choice defaults on, * v11 → v12: background delivery. The device choice defaults on,
* as the entity's column default says; the high-water mark starts empty, so * as the entity's column default says; the high-water mark starts empty, so
* the first catch-up sets it without announcing anything. * the first catch-up sets it without announcing anything.
*/ */
@@ -33,11 +33,10 @@ object DatabaseModule {
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase = fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME) Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
// Pre-v1 safety net: rebuild on any schema mismatch. The sync // Explicit migrations from v8 onward; an older schema has no path
// controller refills the cache from the server on first // and rebuilds. The sync controller refills the cache from the
// launch, so users lose only the unsynced mutation queue // server on first launch, so a rebuild loses only the unsynced
// (acceptable while we're iterating). Replace with explicit // mutation queue.
// Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12) .addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
.fallbackToDestructiveMigration(dropAllTables = true) .fallbackToDestructiveMigration(dropAllTables = true)
.build() .build()
@@ -4,8 +4,7 @@ import androidx.room.TypeConverter
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Source category for a cached audio file. Mirrors the Drift `CacheSource` * Source category for a cached audio file in `audio_cache_index`.
* enum used by the Flutter client's `audio_cache_index` table 1:1.
* *
* - `MANUAL`: user explicitly pinned (e.g. "save for offline") * - `MANUAL`: user explicitly pinned (e.g. "save for offline")
* - `AUTO_LIKED`: auto-cached because the track is liked * - `AUTO_LIKED`: auto-cached because the track is liked
@@ -13,8 +12,10 @@ import kotlinx.datetime.Instant
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics * - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
* - `INCIDENTAL`: cached as a side effect of playback streaming * - `INCIDENTAL`: cached as a side effect of playback streaming
* *
* Eviction priority order (first to evict): INCIDENTAL → AUTO_PREFETCH * Eviction priority (first to evict): INCIDENTAL → AUTO_PREFETCH
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL. * → AUTO_PLAYLIST → AUTO_LIKED → MANUAL. Only INCIDENTAL is written
* today, and SimpleCache evicts by plain LRU, so nothing applies the
* order yet.
*/ */
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL } enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
@@ -22,10 +23,8 @@ enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCID
* Cross-cutting Room type converters. Registered on `@Database` via * Cross-cutting Room type converters. Registered on `@Database` via
* `@TypeConverters(MinstrelTypeConverters::class)`. * `@TypeConverters(MinstrelTypeConverters::class)`.
* *
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: the same convention the * `kotlinx.datetime.Instant` ↔ `Long` epoch millis: leaner than ISO-8601
* Flutter client uses (Drift `text()` column with ISO-8601 strings would * text, and nothing needs human-readable rows in the SQLite file.
* also work but Long is leaner; we don't need human-readable rows in the
* SQLite file).
*/ */
class MinstrelTypeConverters { class MinstrelTypeConverters {
@TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds() @TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds()
@@ -44,10 +44,9 @@ interface CachedLikeDao {
/** /**
* Atomically replaces the user's entire cached_likes set with * Atomically replaces the user's entire cached_likes set with
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds] * [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
* so cross-device unlikes (a row that the server no longer * so an unlike made on another device (a row the server no longer
* surfaces) get removed from the local cache — drift #570 * surfaces) leaves the local cache too; upserts alone would keep a
* caught the missing delete pass that left stale Liked tab * stale Liked tab entry forever.
* entries pointing at tracks the user had unliked elsewhere.
*/ */
@Transaction @Transaction
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) { suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
@@ -46,13 +46,13 @@ interface CachedPlaylistTrackDao {
* Atomic on purpose. Room's InvalidationTracker only notifies observers * Atomic on purpose. Room's InvalidationTracker only notifies observers
* after the transaction commits, so [observeByPlaylist] never sees the * after the transaction commits, so [observeByPlaylist] never sees the
* empty gap between the delete and the re-insert. Un-transacted, that * empty gap between the delete and the re-insert. Un-transacted, that
* gap is a real observed state — it's what made every Home row visibly * gap is a real observed state: the list visibly collapses to empty
* collapse to empty and refill before issue #2327 fixed the equivalent * and refills, which is why `CachedHomeIndexDao` transacts its writes
* write in `CachedHomeIndexDao`. * too.
* *
* Nothing observes [observeByPlaylist] live today, so this is * Nothing observes [observeByPlaylist] live today, so this is
* pre-emptive: it means making playlist detail cache-first later can't * pre-emptive: making playlist detail cache-first later can't
* silently reintroduce that flicker. * silently bring that flicker in.
*/ */
@Transaction @Transaction
suspend fun replacePlaylistTracks( suspend fun replacePlaylistTracks(
@@ -41,8 +41,7 @@ interface CachedQuarantineDao {
/** /**
* Atomic full-replace from the server snapshot — one transaction so * Atomic full-replace from the server snapshot — one transaction so
* the `observeAll()` watcher sees a single merged emission, not a * the `observeAll()` watcher sees a single merged emission, not a
* delete-then-insert flicker. Mirrors Flutter's MyQuarantineController * delete-then-insert flicker.
* `_refreshFromServer` transaction.
*/ */
@Transaction @Transaction
suspend fun replaceAll(rows: List<CachedQuarantineEntity>) { suspend fun replaceAll(rows: List<CachedQuarantineEntity>) {
@@ -39,7 +39,7 @@ interface CachedTrackDao {
suspend fun upsertAll(rows: List<CachedTrackEntity>) suspend fun upsertAll(rows: List<CachedTrackEntity>)
/** /**
* ReplayGain values for [ids] (M464 #5000): the track's own from its row, * ReplayGain values for [ids]: the track's own from its row,
* the album's from its album row. A track not in the cache has no row. * the album's from its album row. A track not in the cache has no row.
*/ */
@Query( @Query(
@@ -7,14 +7,12 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* One row per fully-downloaded audio file. Mirrors * One row per track that has played through the audio cache (see
* the Flutter client's `AudioCacheIndex` Drift table. * `CacheIndexer`); SimpleCache, not this table, says what is on disk.
*
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
* - `incidental` files (streamed-and-cached side effect) evict first
* - `autoPrefetch` second
* - `manual` (user explicitly pinned) evict last
* *
* The `source` column is meant to drive a 2-bucket eviction
* (incidental first, then auto-prefetch, manual last). Nothing evicts
* by it yet: SimpleCache evicts by plain LRU over the rolling cap.
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last * `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
* played it; drives the offline "recently played" ordering and is the * played it; drives the offline "recently played" ordering and is the
* tiebreaker for LRU eviction within a source bucket. Nullable on * tiebreaker for LRU eviction within a source bucket. Nullable on
@@ -39,7 +39,7 @@ data class AuthSessionEntity(
*/ */
val cacheSettingsJson: String? = null, val cacheSettingsJson: String? = null,
/** /**
* Per-device opt-out of diagnostics reporting (M9). When the * Per-device opt-out of diagnostics reporting. When the
* account's debug mode is enabled by an admin, the user can still * account's debug mode is enabled by an admin, the user can still
* turn reporting OFF on this device (battery/privacy) — that local * turn reporting OFF on this device (battery/privacy) — that local
* choice lives here. Default false = honor the account flag. * choice lives here. Default false = honor the account flag.
@@ -47,12 +47,12 @@ data class AuthSessionEntity(
val diagnosticsOptOut: Boolean = false, val diagnosticsOptOut: Boolean = false,
/** /**
* JSON-encoded NormalizationPrefs (settings/data), the last value seen * JSON-encoded NormalizationPrefs (settings/data), the last value seen
* from the server or set here (M464 #4998). Null = never fetched; the * from the server or set here. Null = never fetched; the
* defaults apply. Kept so offline playback still levels. * defaults apply. Kept so offline playback still levels.
*/ */
val normalizationJson: String? = null, val normalizationJson: String? = null,
/** /**
* "Notifications when the app is closed" (M489 #5347): keep the * "Notifications when the app is closed": keep the
* delivery foreground service running. A device choice, on by default. * delivery foreground service running. A device choice, on by default.
* The column default matches MIGRATION_11_12's, which Room checks. * The column default matches MIGRATION_11_12's, which Room checks.
*/ */
@@ -5,10 +5,7 @@ import androidx.room.PrimaryKey
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /** Cache row for one album. */
* Cache row for one album. Mirrors the Flutter client's
* `CachedAlbums` Drift table.
*/
@Entity(tableName = "cached_albums") @Entity(tableName = "cached_albums")
data class CachedAlbumEntity( data class CachedAlbumEntity(
@PrimaryKey val id: String, @PrimaryKey val id: String,
@@ -18,7 +15,7 @@ data class CachedAlbumEntity(
val releaseDate: String? = null, val releaseDate: String? = null,
val coverPath: String? = null, val coverPath: String? = null,
val mbid: String? = null, val mbid: String? = null,
// ReplayGain 2.0 album values (M464); null until every track is measured. // ReplayGain 2.0 album values; null until every track is measured.
val albumGain: Float? = null, val albumGain: Float? = null,
val albumPeak: Float? = null, val albumPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(), val fetchedAt: Instant = Clock.System.now(),
@@ -6,13 +6,11 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Cache row for one artist. Mirrors the Flutter client's * Cache row for one artist.
* `CachedArtists` Drift table.
* *
* Column names follow Kotlin idiom (camelCase) rather than Drift's * Column names follow Kotlin idiom (camelCase) — the schema is purely
* snake_case — the schema is purely internal to the native client; the * internal to the app; the sync controller exchanges snake_case JSON
* sync controller exchanges snake_case JSON with the server and our * with the server and our mappers translate at the boundary.
* mappers translate at the boundary.
*/ */
@Entity(tableName = "cached_artists") @Entity(tableName = "cached_artists")
data class CachedArtistEntity( data class CachedArtistEntity(
@@ -8,8 +8,7 @@ import kotlinx.datetime.Instant
/** /**
* Single-row snapshot of the `/api/me/history` response, stored as the * Single-row snapshot of the `/api/me/history` response, stored as the
* raw wire JSON so the History tab paints instantly from disk on cold * raw wire JSON so the History tab paints instantly from disk on cold
* open and survives connectivity loss. Mirrors the Flutter Drift * open and survives connectivity loss. One row total; id defaults to 1.
* `CachedHistorySnapshot` table (id defaults to 1; one row total).
* *
* Cache-first + SWR: the tab observes this row and a background refresh * Cache-first + SWR: the tab observes this row and a background refresh
* overwrites it, so the freshest plays land without a blocking spinner. * overwrites it, so the freshest plays land without a blocking spinner.
@@ -5,8 +5,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Per-item row driving the Home screen sections. Mirrors * Per-item row driving the Home screen sections.
* 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,14 +5,13 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Like membership row. Mirrors the Flutter client's * Like membership row. 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.
* *
* `entityType` is one of "track" | "album" | "artist". Stored as a * `entityType` is one of "track" | "album" | "artist". Stored as a
* plain string for parity with the Drift schema and the server's wire * plain string to match the server's wire format; an enum doesn't
* format; an enum doesn't earn its keep here. * earn its keep here.
*/ */
@Entity( @Entity(
tableName = "cached_likes", tableName = "cached_likes",
@@ -6,12 +6,11 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* One row per pending offline-write. Mirrors * One row per pending offline write.
* 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
* row when connectivity returns (Phase 12.2). * row when connectivity returns.
* *
* `kind` is a stable string registered in `MutationKind` — the replayer * `kind` is a stable string registered in `MutationKind` — the replayer
* looks up the handler in a kind→suspend-function map. Unknown kinds * looks up the handler in a kind→suspend-function map. Unknown kinds
@@ -5,7 +5,7 @@ import androidx.room.PrimaryKey
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* One notice from the user's inbox (M489), kept so the Notifications screen * One notice from the user's inbox, kept so the Notifications screen
* and the bell's badge work offline, and so a read made offline shows at once. * and the bell's badge work offline, and so a read made offline shows at once.
* The newest page is cached; older notices are the server's to keep. * The newest page is cached; older notices are the server's to keep.
*/ */
@@ -4,7 +4,7 @@ import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
/** /**
* Single-row copy of the user's notification settings (M489), stored as the * Single-row copy of the user's notification settings, stored as the
* wire JSON, so the settings screen opens offline and a toggle shows at once. * wire JSON, so the settings screen opens offline and a toggle shows at once.
*/ */
@Entity(tableName = "cached_notification_settings") @Entity(tableName = "cached_notification_settings")
@@ -6,8 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Cache row for one playlist (user or system). Mirrors * Cache row for one playlist (user or system).
* 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.
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity import androidx.room.Entity
/** /**
* Ordered membership of tracks within a playlist. Mirrors * Ordered membership of tracks within a playlist.
* 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.
*/ */
@@ -6,9 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* The current user's quarantine flag for one track. Mirrors * The current user's quarantine flag for one track.
* the Flutter client's `CachedQuarantineMine` Drift
* table.
* *
* The flat denormalized track/album/artist columns let the Quarantine * The flat denormalized track/album/artist columns let the Quarantine
* screen render off this single table without a join — the server * screen render off this single table without a join — the server
@@ -7,18 +7,15 @@ 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.
* 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
* button has nothing to act on. The Flutter codebase persists this on * button has nothing to act on.
* every queue change and reads on cold start.
* *
* The `json` field carries the entire ResumeState shape (queue trackrefs, * The `json` field carries the entire ResumeState shape (queue trackrefs,
* currentIndex, positionMs, sourceTag). Kotlin-side deserialization * currentIndex, positionMs, sourceTag). The ResumeController decodes it,
* happens in the ResumeController (Phase 6.5) so the schema stays * so the schema stays stable across resume-shape changes.
* stable across resume-shape changes.
*/ */
@Entity(tableName = "cached_resume_state") @Entity(tableName = "cached_resume_state")
data class CachedResumeStateEntity( data class CachedResumeStateEntity(
@@ -6,10 +6,9 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Cache row for one track. Mirrors the Flutter client's * Cache row for one track.
* `CachedTracks` Drift table.
* *
* [missing] carries the server's missing-file mark (#2704). Every read that * [missing] carries the server's missing-file mark. Every read that
* can put a track in front of the user — or in a queue — must exclude it, and * can put a track in front of the user — or in a queue — must exclude it, and
* the DAO queries do that rather than each call site remembering to. * the DAO queries do that rather than each call site remembering to.
*/ */
@@ -26,7 +25,7 @@ data class CachedTrackEntity(
val fileFormat: String? = null, val fileFormat: String? = null,
val genre: String? = null, val genre: String? = null,
val missing: Boolean = false, val missing: Boolean = false,
// ReplayGain 2.0 track values (M464), kept so cached audio levels // ReplayGain 2.0 track values, kept so cached audio levels
// offline. Null until the server has measured the track. // offline. Null until the server has measured the track.
val trackGain: Float? = null, val trackGain: Float? = null,
val trackPeak: Float? = null, val trackPeak: Float? = null,
@@ -4,14 +4,14 @@ import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
/** /**
* One buffered device-diagnostics event (M9). The DiagnosticsReporter * One buffered device-diagnostics event. The DiagnosticsReporter
* writes rows here when the account's debug mode is on; the * writes rows here when the account's debug mode is on; the
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes * DiagnosticsUploader drains them to POST /api/diagnostics and deletes
* on success. * on success.
* *
* This is a deliberate ring buffer that is NOT routed through the offline * This is a deliberate ring buffer that is NOT routed through the offline
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and * MutationQueue: diagnostics are high-volume, best-effort telemetry, and
* the bug we're chasing (roaming / dead-zone recovery) happens WHILE * the failures they record (roaming / dead-zone recovery) happen WHILE
* offline — so events must persist locally through the dead zone and * offline — so events must persist locally through the dead zone and
* upload on recovery, without clogging the user-data mutation replay. * upload on recovery, without clogging the user-data mutation replay.
* *
@@ -7,12 +7,8 @@ import kotlinx.datetime.Instant
/** /**
* Single-row table that tracks where the SyncController left off. * Single-row table that tracks where the SyncController left off.
* *
* Mirrors the Flutter Drift `sync_metadata` table. There is only ever * There is only ever one row at `id = 0`; the SyncController upserts it
* one row at `id = 0`; the SyncController upserts on every successful * on every successful `/api/library/sync` call.
* `/api/sync` call.
*
* Pulled into Task 4.1 to give Room a compileable @Database (it needs
* at least one entity); the SyncController itself lands in Phase 12.4.
*/ */
@Entity(tableName = "sync_metadata") @Entity(tableName = "sync_metadata")
data class SyncMetadataEntity( data class SyncMetadataEntity(
@@ -33,30 +33,29 @@ object MutationKind {
// Durable close-by-id for a play whose server row is already open // Durable close-by-id for a play whose server row is already open
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records // (play_started succeeded). Distinct from PLAY_OFFLINE, which records
// a *whole* play when no server row exists. Using close-by-id on // a *whole* play when no server row exists. Recording a whole play on
// background avoids the duplicate + orphan row the old offline-on-stop // background would duplicate the open row and leave it orphaned.
// path produced (see 2026-06-11 contract audit).
const val PLAY_ENDED: String = "play_ended" const val PLAY_ENDED: String = "play_ended"
// #2374 suggestion snooze. ONE toggle kind rather than separate // Suggestion snooze. ONE toggle kind rather than separate
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by // snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
// an undo collapses to the latest intent instead of replaying as two // an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome. // opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle" const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
// M464 #4998 loudness-normalization preference. The payload is the whole // Loudness-normalization preference. The payload is the whole
// preference, a target state like the toggles above, so queued changes // preference, a target state like the toggles above, so queued changes
// collapse to the last one and an older one can never be replayed last. // collapse to the last one and an older one can never be replayed last.
const val NORMALIZATION_SET: String = "normalization_set" const val NORMALIZATION_SET: String = "normalization_set"
// M489 notifications inbox. A read is a one-way action (read never goes // Notifications inbox. A read is a one-way action (read never goes
// back to unread), so neither read kind needs collapsing. Read-all // back to unread), so neither read kind needs collapsing. Read-all
// carries the moment the user asked, so a late replay leaves newer // carries the moment the user asked, so a late replay leaves newer
// notices unread. // notices unread.
const val NOTIFICATION_READ: String = "notification_read" const val NOTIFICATION_READ: String = "notification_read"
const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all" const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all"
// M489 per-kind channel setting. One row per (kind, channel) target // Per-kind notification channel. One row per (kind, channel) target
// state, collapsed on that pair, so the newest choice is the one sent. // state, collapsed on that pair, so the newest choice is the one sent.
const val NOTIFICATION_SETTING_SET: String = "notification_setting_set" const val NOTIFICATION_SETTING_SET: String = "notification_setting_set"
} }
@@ -92,16 +91,13 @@ data class RequestCreatePayload(
) )
/** /**
* Minimal write-side of the offline mutation queue. Phase 8 v1 only * Write side of the offline mutation queue; [MutationReplayer] drains it.
* enqueues — the connectivity-aware replayer that drains the queue
* lands with Phase 12.2's SyncController.
* *
* Callers (LikesRepository et al.) follow the optimistic-write * Callers (LikesRepository et al.) follow the optimistic-write
* pattern: mutate Room first so the UI reflects the change * pattern: mutate Room first so the UI reflects the change
* immediately, fire the REST call as a best-effort attempt, and on * immediately, fire the REST call as a best-effort attempt, and on
* failure enqueue here so the replayer eventually pushes it through. * failure enqueue here so the replayer eventually pushes it through.
* This matches `feedback_offline_first_for_server_writes` — writes * Writes never go fire-and-forget.
* never go fire-and-forget.
*/ */
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers @Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
@Singleton @Singleton
@@ -262,7 +258,7 @@ class MutationQueue @Inject constructor(
} }
/** /**
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374). * Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE`.
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so * `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
* the replayer can collapse repeated toggles for one candidate down to the * the replayer can collapse repeated toggles for one candidate down to the
* last intent. Both directions are idempotent server-side: re-snoozing * last intent. Both directions are idempotent server-side: re-snoozing
@@ -285,10 +281,10 @@ data class SuggestionSnoozeTogglePayload(
data class QuarantineUnflagPayload(val trackId: String) data class QuarantineUnflagPayload(val trackId: String)
/** /**
* Persisted payload for `MutationKind.PLAYLIST_APPEND`. Mirrors the * Persisted payload for `MutationKind.PLAYLIST_APPEND`: the track IDs to
* Flutter wire shape: list of track IDs to append to a specific * append to one playlist. The server is idempotent at the
* playlist. Server is idempotent at the (playlist_id, track_id) pair * (playlist_id, track_id) pair level — re-firing the same payload yields
* level — re-firing the same payload yields no duplicates. * no duplicates.
*/ */
@Serializable @Serializable
data class PlaylistAppendPayload( data class PlaylistAppendPayload(
@@ -314,7 +310,7 @@ data class QuarantineFlagPayload(
* re-fires `POST /api/events` with type=play_offline; the server * re-fires `POST /api/events` with type=play_offline; the server
* records start+end from `atIso` (the original play-start time) * records start+end from `atIso` (the original play-start time)
* + `durationPlayedMs`, applies the canonical skip rule, and * + `durationPlayedMs`, applies the canonical skip rule, and
* advances #415 rotation when source is a system playlist. * advances rotation when source is a system playlist.
*/ */
@Serializable @Serializable
data class PlayOfflinePayload( data class PlayOfflinePayload(
@@ -323,7 +319,7 @@ data class PlayOfflinePayload(
val atIso: String, val atIso: String,
val durationPlayedMs: Long, val durationPlayedMs: Long,
val source: String? = null, val source: String? = null,
// #1551: device class for context conditioning; null on payloads queued // Device class for context conditioning; null on payloads queued
// before this field existed (decodes to null → server stores NULL). // before this field existed (decodes to null → server stores NULL).
val deviceClass: String? = null, val deviceClass: String? = null,
) )
@@ -367,12 +363,12 @@ data class PlaybackErrorReportPayload(
val clientId: String, val clientId: String,
) )
/** Persisted payload for `MutationKind.NOTIFICATION_READ` (M489). */ /** Persisted payload for `MutationKind.NOTIFICATION_READ`. */
@Serializable @Serializable
data class NotificationReadPayload(val id: String) data class NotificationReadPayload(val id: String)
/** /**
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL` (M489). * Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL`.
* `upToIso` is the newest notice the user could see when they asked; null * `upToIso` is the newest notice the user could see when they asked; null
* when the inbox was empty on the device, which marks everything. * when the inbox was empty on the device, which marks everything.
*/ */
@@ -380,7 +376,7 @@ data class NotificationReadPayload(val id: String)
data class NotificationsReadAllPayload(val upToIso: String?) data class NotificationsReadAllPayload(val upToIso: String?)
/** /**
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET` (M489): one * Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET`: one
* kind's one channel, as a target state. `channel` is "inbox" | "phone" | * kind's one channel, as a target state. `channel` is "inbox" | "phone" |
* "email". No defaults, so every field is always written. * "email". No defaults, so every field is always written.
*/ */
@@ -160,7 +160,7 @@ class MutationReplayer @Inject constructor(
/** /**
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an * Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
* unknown kind/variant. Network / HTTP failures THROW and are classified * unknown kind/variant. Network / HTTP failures THROW and are classified
* by [outcomeFor] — dispatchers no longer swallow. * by [outcomeFor] — dispatchers never swallow them.
*/ */
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) { private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload) MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
@@ -182,7 +182,7 @@ class MutationReplayer @Inject constructor(
else -> Outcome.DROP else -> Outcome.DROP
} }
/** The notifications inbox's kinds (M489), split out to keep [dispatch] simple. */ /** The notifications inbox's kinds, split out to keep [dispatch] simple. */
private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) { private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) {
MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload) MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload)
MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload) MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload)
@@ -354,8 +354,8 @@ class MutationReplayer @Inject constructor(
/** /**
* Row ids of desired-state toggles superseded by a later toggle for the same * Row ids of desired-state toggles superseded by a later toggle for the same
* entity. Applies to every kind whose payload encodes a TARGET state rather * entity. Applies to every kind whose payload encodes a TARGET state rather
* than an action — like-toggles, suggestion snoozes (#2374) and the * than an action — like-toggles, suggestion snoozes and the
* normalization preference (#4998) — because * normalization preference — because
* replaying a stale one last would invert the final state. * replaying a stale one last would invert the final state.
* *
* Top-level and pure so it can be unit-tested without standing up a Retrofit * Top-level and pure so it can be unit-tested without standing up a Retrofit
@@ -34,9 +34,8 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Drives the delta sync against `/api/library/sync` (#357). Mirrors * Drives the delta sync against `/api/library/sync`, scoped to the
* the Flutter `SyncController`, scoped to the entity types the * entity types the app caches (artist/album/track). Likes and
* native client cares about (artist/album/track). Likes and
* playlists get their own dedicated refresh paths so the sync * playlists get their own dedicated refresh paths so the sync
* controller doesn't need to track them. * controller doesn't need to track them.
* *
@@ -46,10 +45,9 @@ import javax.inject.Singleton
* with a persisted cookie). Re-running on every cookie change is * with a persisted cookie). Re-running on every cookie change is
* what makes the Library tabs populate within seconds of sign-in. * what makes the Library tabs populate within seconds of sign-in.
* *
* Wipes-on-410: when the server's compaction window has passed the * Restart-on-410: when the server's compaction window has passed the
* stored cursor, the response is 410 Gone. The controller drops the * stored cursor, the response is 410 Gone. The controller resets the
* three local caches and recurses with cursor=0 — same wipe behavior * cursor to 0 and pulls the full entity set again (see [resetCursor]).
* as the Flutter port.
*/ */
@Singleton @Singleton
class SyncController @Inject constructor( class SyncController @Inject constructor(
@@ -110,11 +108,11 @@ class SyncController @Inject constructor(
} }
/** /**
* Pulls pages until the server has nothing newer (#5654). The server * Pulls pages until the server has nothing newer. The server returns
* returns at most 1000 changes a call; taking one page per trigger left * at most 1000 changes a call, and a full library re-read adds tens
* the cache behind a change log that a full re-read grows by ~26,000 * of thousands, so one page per trigger would leave the cache behind:
* rows, so merge deletes never arrived and the freshness sweep kept * merge deletes would never arrive and the freshness sweep would keep
* asking for tracks that were gone. The cursor is stored after each * asking for tracks that are gone. The cursor is stored after each
* page, so an interrupted pass resumes where it stopped. * page, so an interrupted pass resumes where it stopped.
*/ */
private suspend fun sync() { private suspend fun sync() {
@@ -153,7 +151,7 @@ class SyncController @Inject constructor(
body.cursor > cursor body.cursor > cursor
} }
else -> { else -> {
// Unhandled status — treat as a no-op for v1; the next // Unhandled status — treat as a no-op; the next
// app start retries from the same cursor. // app start retries from the same cursor.
false false
} }
@@ -29,27 +29,25 @@ private const val POLL_DEGRADED_MS = 20_000L
private const val ARBITRATE_MIN_GAP_MS = 2_000L private const val ARBITRATE_MIN_GAP_MS = 2_000L
/** /**
* THE single authority on network/server reachability. Absorbs the former * THE single authority on network/server reachability: the /healthz poll,
* VersionCheckController (the /healthz poll + version parsing) and * version parsing and the tri-state health derive live in one
* ServerHealthController (the tri-state derive) into one signal-driven unit so * signal-driven unit, so the app has one place that answers "can we reach
* the app has one place that answers "can we reach Minstrel?" — not three * Minstrel?".
* half-systems reporting differently.
* *
* Inputs (all funnel through a single-consumer intent channel for thread * Inputs (all funnel through a single-consumer intent channel for thread
* safety — [reportSuccess]/[reportFailure] are called from the audio read path * safety — [reportSuccess]/[reportFailure] are called from the audio read path
* and OkHttp threads): * and OkHttp threads):
* - device link transitions ([ConnectivityObserver]); link-return triggers an * - device link transitions ([ConnectivityObserver]); link-return triggers an
* immediate probe so recovery is near-instant (fixes the sticky banner). * immediate probe so recovery is near-instant.
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down). * - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
* - reportSuccess / reportFailure from the API interceptor, the audio data * - reportSuccess / reportFailure from the API interceptor, the audio data
* source, and the playback-error reporter. * source, and the playback-error reporter.
* - recheck() from pull-to-refresh and the banner. * - recheck() from pull-to-refresh and the banner.
* - a forced probe when the app returns to the foreground (#1209). Without * - a forced probe when the app returns to the foreground. The poll
* it a stale ServerDown outlived the condition that caused it: the poll * loop's delay() is throttled while screen-off/doze, so without it a
* loop's delay() is throttled while screen-off/doze, so recovery waited on * stale ServerDown would outlive its cause until the OS next let the
* whenever the OS next let the loop run. Meanwhile ServerDown makes * loop run — and ServerDown makes OfflineGatedDataSource refuse every
* OfflineGatedDataSource refuse every uncached track, so the app declined * uncached track, declining music that would play fine.
* to play music that would have played fine.
* *
* Version compatibility is a byproduct of the same /healthz response. * Version compatibility is a byproduct of the same /healthz response.
* *
@@ -112,7 +110,7 @@ class NetworkStatusController @Inject constructor(
/** /**
* App returned to the foreground — probe now rather than waiting for the * App returned to the foreground — probe now rather than waiting for the
* poll loop (#1209). * poll loop.
* *
* The link-return probe in `init` does NOT cover this: it fires on a * The link-return probe in `init` does NOT cover this: it fires on a
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none. * connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
@@ -172,7 +170,7 @@ class NetworkStatusController @Inject constructor(
private suspend fun probeOnce(force: Boolean = false) { private suspend fun probeOnce(force: Boolean = false) {
// Startup guard: don't poll the localhost placeholder before AuthStore // Startup guard: don't poll the localhost placeholder before AuthStore
// hydrates the real base URL — that false failure used to flash the banner. // hydrates the real base URL — that false failure would flash the banner.
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
@@ -5,15 +5,14 @@ internal const val CORROBORATION_WINDOW_MS = 30_000L
internal const val CORROBORATION_OP_THRESHOLD = 2 internal const val CORROBORATION_OP_THRESHOLD = 2
/** /**
* Minimum gap between op failures for them to count as SEPARATE evidence * Minimum gap between op failures for them to count as SEPARATE evidence.
* (#1209).
* *
* A link handoff fails every in-flight request at once, so a burst is one * A link handoff fails every in-flight request at once, so a burst is one
* event producing N failures — not N independent observations that the server * event producing N failures — not N independent observations that the server
* is gone. Without this, two simultaneous failures corroborated each other * is gone. Counted separately, two simultaneous failures would corroborate
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse * each other straight to Unreachable, and ServerDown makes
* every uncached track. The app declined to play music that would have played * OfflineGatedDataSource refuse every uncached track: the app would decline
* fine, for a blip that had already resolved. * music that plays fine, for a blip that had already resolved.
* *
* 3s is comfortably above the sub-second window an OS handoff occupies while * 3s is comfortably above the sub-second window an OS handoff occupies while
* still letting a genuine outage corroborate within seconds once a client * still letting a genuine outage corroborate within seconds once a client
@@ -38,7 +38,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* On-device diagnostics collector (M9). Active only while the account's * On-device diagnostics collector. Active only while the account's
* debug mode is enabled (server flag, read via AuthController.currentUser) * debug mode is enabled (server flag, read via AuthController.currentUser)
* AND the user hasn't opted out locally. When active it taps existing * AND the user hasn't opted out locally. When active it taps existing
* connectivity / player / UPnP / power seams READ-ONLY and writes a * connectivity / player / UPnP / power seams READ-ONLY and writes a
@@ -196,13 +196,11 @@ class DiagnosticsReporter @Inject constructor(
/** /**
* Catch the renderer rapidly leaving and re-entering PLAYING. * Catch the renderer rapidly leaving and re-entering PLAYING.
* *
* The operator reports the Sonos "play pause play pause, like someone * The symptom: a Sonos rapidly play-pause-play-pausing, about every half
* pressing it every half second", usually as a track starts, cleared by a * second, usually as a track starts, cleared by a manual pause or skip.
* manual pause or skip. Nothing here could see that: `player_state` * No other collector sees it: `player_state` carries
* carries source/loading/error but not playing, `track_change` needs the * source/loading/error but not playing, `track_change` needs the index
* index to move, and the heartbeat samples once per 45s. The symptom fell * to move, and the heartbeat samples once per 45s.
* through every existing collector, which is why it has only ever been
* described and never measured.
* *
* Records every raw transport change (cheap — steady playback produces * Records every raw transport change (cheap — steady playback produces
* a couple per track) and, when they come in a burst, one summary event * a couple per track) and, when they come in a burst, one summary event
@@ -27,7 +27,7 @@ import javax.inject.Singleton
/** /**
* Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics * Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics
* (M9). Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]: *. Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
* a periodic tick, a sign-in (sessionCookie non-null), and a server-health * a periodic tick, a sign-in (sessionCookie non-null), and a server-health
* recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events * recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events
* upload the moment the link comes back. * upload the moment the link comes back.
@@ -6,10 +6,9 @@ import com.fabledsword.minstrel.player.TransportObservation
* Decides when a run of renderer transport changes is a *flap* — the renderer * Decides when a run of renderer transport changes is a *flap* — the renderer
* repeatedly failing to settle — rather than an ordinary track transition. * repeatedly failing to settle — rather than an ordinary track transition.
* *
* The operator reports the Sonos "play pause play pause, like someone pressing * The symptom: a Sonos rapidly play-pause-play-pausing, about every half
* it every half second", usually as a track starts. No diagnostic event could * second, usually as a track starts. No other diagnostic event records it;
* see it, so it has been described several times and measured never. This is * this is the rule that decides when an episode is worth writing down.
* the rule that decides when an episode is worth writing down.
* *
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]: * Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
* the caller owns the flow and the recording, this only answers "is this an * the caller owns the flow and the recording, this only answers "is this an
@@ -29,16 +29,11 @@ enum class RequestOutcome { ACCEPTED, QUEUED }
/** /**
* Read-through Discover surface: catalogue search, suggestion feed, and * Read-through Discover surface: catalogue search, suggestion feed, and
* the request-creation write path. Mirrors the Flutter * the request-creation write path. Writes never go fire-and-forget, so
* `DiscoverApi` + `mutationQueueProvider` integration — * `createRequest` falls back to the MutationQueue on transport failure.
* `feedback_offline_first_for_server_writes` says writes never go
* fire-and-forget, so `createRequest` falls back to the
* MutationQueue on transport failure.
* *
* Reads are not cached locally yet — discovery results are inherently * Reads are not cached locally — discovery results are inherently
* transient (60s server-side LRU); the cache wins are minor and * transient (60s server-side LRU), so a local cache would win little.
* unblock no UX. Phase 13's offline pass adds an offline scrollback
* for the suggestion feed only.
*/ */
@Singleton @Singleton
class DiscoverRepository @Inject constructor( class DiscoverRepository @Inject constructor(
@@ -54,7 +49,7 @@ class DiscoverRepository @Inject constructor(
api.listSnoozes().map { it.toDomain() } api.listSnoozes().map { it.toDomain() }
/** /**
* Parks a suggestion ("not right now"). Offline-first per rule #100: on * Parks a suggestion ("not right now"). Offline-first: on
* transport failure the target state is queued for the replayer rather * transport failure the target state is queued for the replayer rather
* than dropped. * than dropped.
* *
@@ -195,8 +195,8 @@ private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
/** /**
* The snooze surface's data and callbacks, bundled rather than threaded * The snooze surface's data and callbacks, bundled rather than threaded
* through as four more parameters — the pane grew from one action to three * through as separate parameters: the pane carries three actions, and the
* with slice 4 and the signatures stopped being readable. * signatures stop being readable otherwise.
*/ */
private data class SnoozeUi( private data class SnoozeUi(
val locallySnoozedMbids: Set<String>, val locallySnoozedMbids: Set<String>,
@@ -240,9 +240,9 @@ private fun SuggestionsList(
) { ) {
item { SuggestionsHeader() } item { SuggestionsHeader() }
if (items.isEmpty()) { if (items.isEmpty()) {
// An empty deck used to mean one thing — no listening signal yet. // An empty deck means one of two things — no listening signal yet,
// With snoozing it can also mean "you parked them all", and telling // or "you parked them all" — and telling the second user to go
// that user to go listen to something would be wrong advice. // listen to something would be wrong advice.
item { item {
CenteredMessage( CenteredMessage(
if (snoozeUi.snoozes.isEmpty()) { if (snoozeUi.snoozes.isEmpty()) {
@@ -79,8 +79,8 @@ internal fun SuggestionTile(
IconButton(onClick = onSnooze) { IconButton(onClick = onSnooze) {
Icon( Icon(
imageVector = Lucide.Clock, imageVector = Lucide.Clock,
// Rule #101: the label states what happens, and passes no // The label states what happens and passes no judgement on the
// judgement on the music. Never "not for me". // music: never "not for me".
contentDescription = "Not right now — hide ${s.name} for a while", contentDescription = "Not right now — hide ${s.name} for a while",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -173,7 +173,7 @@ private fun Avatar(imageUrl: String, fallback: ImageVector, circular: Boolean) {
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
// The server hands back its own art endpoint (relative, M502 #5519), // The server hands back its own art endpoint (relative),
// which answers 404 when no source has an image: ServerImage resolves // which answers 404 when no source has an image: ServerImage resolves
// the path and shows the icon while loading and on a miss. // the path and shows the icon while loading and on a miss.
ServerImage( ServerImage(
@@ -68,7 +68,7 @@ class DiscoverViewModel @Inject constructor(
init { init {
loadSuggestions() loadSuggestions()
// Screen-level auto-recovery (issue #1245): re-run whichever pane // Screen-level auto-recovery: re-run whichever pane
// failed while the server was unreachable once health returns. // failed while the server was unreachable once health returns.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { networkStatus.recoveries().collect {
@@ -54,7 +54,7 @@ private const val EVENTS_BUFFER_CAPACITY = 64
* retry. Without this a single blip silently kills cross-device * retry. Without this a single blip silently kills cross-device
* reactivity until the next app launch. * reactivity until the next app launch.
* - [connected] says whether a stream is open. Background delivery * - [connected] says whether a stream is open. Background delivery
* (M489 #5347) catches up on every rising edge: nothing replays a * catches up on every rising edge: nothing replays a
* frame sent while the stream was down. * frame sent while the stream was down.
* *
* The URL passes through the placeholder host that * The URL passes through the placeholder host that
@@ -4,8 +4,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
/** /**
* Parsed event from the server's SSE stream. Mirrors * Parsed event from the server's SSE stream.
* 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
@@ -51,7 +51,7 @@ class LiveEventsDispatcher @Inject constructor(
"artist.liked", "artist.liked",
"artist.unliked", "artist.unliked",
-> refreshLikes() -> refreshLikes()
// M489: a contentless nudge; the inbox refetches its newest page. // a contentless nudge; the inbox refetches its newest page.
"notification.created" -> refreshNotifications() "notification.created" -> refreshNotifications()
} }
// Other kinds (playlist.*, quarantine.*, request.status_changed, // Other kinds (playlist.*, quarantine.*, request.status_changed,
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.events
import kotlin.random.Random import kotlin.random.Random
/** /**
* How long [EventsStream] waits before reconnecting (M489 #5347). * How long [EventsStream] waits before reconnecting.
* *
* The stream is wanted around the clock once notifications arrive with the app * The stream is wanted around the clock once notifications arrive with the app
* closed, so a server that is down overnight must not cost a radio wakeup * closed, so a server that is down overnight must not cost a radio wakeup
* every few seconds: Roundtable's flat 2s came to about 43,000 of them. The * every few seconds: a flat 2s retry is 1,800 wakeups an hour. The wait
* wait doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every * doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
* phone on the server reconnects, which is when the server has just come back * phone on the server reconnects, which is when the server has just come
* and can least take a spike. * back and can least take a spike.
* *
* A network coming up (a hint, never a gate) or the app coming to the * A network coming up (a hint, never a gate) or the app coming to the
* foreground reconnects at once and starts the ladder over. * foreground reconnects at once and starts the ladder over.
@@ -33,9 +33,7 @@ data class HistoryPage(
) )
/** /**
* Cache-first listening history. Mirrors Flutter's `_historyProvider` * Cache-first listening history, in the Room + Flow idiom:
* (`cacheFirst` over the `cached_history_snapshot` drift row with
* `alwaysRefresh`) using the native Room + Flow idiom:
* *
* - [observeHistory] emits the cached snapshot instantly (null until * - [observeHistory] emits the cached snapshot instantly (null until
* the first fetch lands), so the History tab paints from disk on * the first fetch lands), so the History tab paints from disk on
@@ -47,8 +45,8 @@ data class HistoryPage(
* *
* The whole page is stored as one JSON blob (single-row table) rather * The whole page is stored as one JSON blob (single-row table) rather
* than per-row Room entities — history is a read-only timestamp-keyed * than per-row Room entities — history is a read-only timestamp-keyed
* snapshot, so a blob is the simplest faithful mirror of the Drift * snapshot, so a blob is the simplest faithful copy and avoids
* snapshot and avoids ordering/paging bookkeeping for no UX gain. * ordering/paging bookkeeping for no UX gain.
*/ */
@Singleton @Singleton
class HistoryRepository @Inject constructor( class HistoryRepository @Inject constructor(
@@ -86,7 +86,7 @@ class HistoryTabViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): re-pull when server // Screen-level auto-recovery: re-pull when server
// health returns, so a load that failed offline heals unprompted. // health returns, so a load that failed offline heals unprompted.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { refresh() } networkStatus.recoveries().collect { refresh() }
@@ -99,7 +99,7 @@ class HistoryTabViewModel @Inject constructor(
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) } .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
} }
/** Single-track play, matching the Flutter history-row behavior. */ /** Plays the tapped track on its own. */
fun playTrack(track: TrackRef) { fun playTrack(track: TrackRef) {
if (track.streamUrl.isEmpty()) return if (track.streamUrl.isEmpty()) return
player.setQueue(listOf(track), initialIndex = 0, source = "history") player.setQueue(listOf(track), initialIndex = 0, source = "history")
@@ -33,8 +33,7 @@ import javax.inject.Singleton
* [MetadataProvider] Flow that emits the cached row (or null while it * [MetadataProvider] Flow that emits the cached row (or null while it
* hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A * hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A
* null [HomeTile.value] renders as a skeleton in position; the tile * null [HomeTile.value] renders as a skeleton in position; the tile
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's * reveals when the fetch lands and Room re-emits.
* per-item tile providers.
* *
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in * `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
* one transaction (so the rows update together in a single emission * one transaction (so the rows update together in a single emission
@@ -99,8 +98,8 @@ class HomeRepository @Inject constructor(
* One transaction for all seven sections is deliberate: Room notifies * One transaction for all seven sections is deliberate: Room notifies
* observers once, on commit, so Home swaps from the old content to * observers once, on commit, so Home swaps from the old content to
* the new in a single emission. Per-section, un-transacted writes * the new in a single emission. Per-section, un-transacted writes
* made every row visibly collapse to empty and refill, one after * would make every row visibly collapse to empty and refill, one
* another (issue #2327). * after another.
*/ */
suspend fun refreshIndex() { suspend fun refreshIndex() {
val wire = api.getHomeIndex() val wire = api.getHomeIndex()
@@ -141,7 +140,7 @@ class HomeRepository @Inject constructor(
* the id list instead means a section whose contents didn't actually * the id list instead means a section whose contents didn't actually
* move never restarts the `flatMapLatest` below, which would * move never restarts the `flatMapLatest` below, which would
* otherwise tear down and rebuild all of its tiles' hydration flows * otherwise tear down and rebuild all of its tiles' hydration flows
* and flicker unchanged tiles (issue #2327). * and flicker unchanged tiles.
*/ */
private fun observeSectionIds(section: String): Flow<List<String>> = private fun observeSectionIds(section: String): Flow<List<String>> =
homeIndexDao.observeBySection(section) homeIndexDao.observeBySection(section)
@@ -133,7 +133,7 @@ private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// "Updating your mixes…" veil. UpdateVeilController decides both whether it // "Updating your mixes…" veil. UpdateVeilController decides both whether it
// appears at all — only when a refresh actually changes something — and how // appears at all — only when a refresh actually changes something — and how
// long it stays, by watching the screen settle rather than by a fixed delay, // long it stays, by watching the screen settle rather than by a fixed delay,
// which lowered it while tiles and artwork were still landing (#2327). // which would lower it while tiles and artwork are still landing.
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through. // Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_WIPE_MS = 280 private const val VEIL_WIPE_MS = 280
private const val VEIL_ALPHA = 0.96f private const val VEIL_ALPHA = 0.96f
@@ -244,9 +244,8 @@ class HomeViewModel @Inject constructor(
/** /**
* Tap on a Most-played tile: replace the queue with the section's * Tap on a Most-played tile: replace the queue with the section's
* tracks and start at the tapped index. Mirrors Flutter's * tracks and start at the tapped index. Home's section is itself the
* `_resolveSectionTracks` — Home's section is itself the playable * playable unit, not a list of album-detail links.
* unit, not a list of album-detail links.
*/ */
fun playMostPlayed(tracks: List<com.fabledsword.minstrel.models.TrackRef>, startIndex: Int) { fun playMostPlayed(tracks: List<com.fabledsword.minstrel.models.TrackRef>, startIndex: Int) {
if (tracks.isEmpty()) return if (tracks.isEmpty()) return
@@ -259,13 +258,11 @@ class HomeViewModel @Inject constructor(
/** /**
* Tap the album play-button overlay: fetch the album detail and * Tap the album play-button overlay: fetch the album detail and
* play its tracks from the first. Mirrors Flutter's AlbumCard * play its tracks from the first. Suspending so the button's spinner
* `_playAlbum`. Suspending so the button's spinner waits for the * waits for the fetch + queue setup; the inner work is launched in
* fetch + queue setup; the inner work is launched in viewModelScope * viewModelScope so cancellation from leaving the screen mid-fetch
* so cancellation from leaving the screen mid-fetch doesn't kill * doesn't kill playback. Errors surface via the snackbar channel,
* playback. Errors surface via the snackbar channel (Android * like the ArtistDetail play handler.
* improvement over Flutter's silent-fail; matches the existing
* ArtistDetail play handler).
*/ */
suspend fun playAlbum(albumId: String) { suspend fun playAlbum(albumId: String) {
viewModelScope.launch { viewModelScope.launch {
@@ -288,9 +285,8 @@ class HomeViewModel @Inject constructor(
/** /**
* Tap the artist play-button overlay: fetch every track across the * Tap the artist play-button overlay: fetch every track across the
* artist's albums, Fisher-Yates shuffle, play from index 0. Mirrors * artist's albums, Fisher-Yates shuffle, play from index 0. Empty +
* Flutter's ArtistCard `_playArtistShuffle`. Empty + error semantics * error semantics follow [playAlbum].
* follow [playAlbum].
*/ */
suspend fun playArtistShuffled(artistId: String) { suspend fun playArtistShuffled(artistId: String) {
viewModelScope.launch { viewModelScope.launch {
@@ -315,9 +311,8 @@ class HomeViewModel @Inject constructor(
* Tap the playlist play-button overlay: fetch the playlist's tracks * Tap the playlist play-button overlay: fetch the playlist's tracks
* (rotation-aware shuffle for refreshable system playlists, stored * (rotation-aware shuffle for refreshable system playlists, stored
* order for everything else), 8s timeout, play from index 0. * order for everything else), 8s timeout, play from index 0.
* Mirrors Flutter PlaylistCard `_playPlaylist`. Empty-mix and * Empty-mix and timeout get their own copy strings; other errors
* timeout get their own copy strings; other errors fall through to * fall through to [ErrorCopy.fromThrowable].
* [ErrorCopy.fromThrowable].
*/ */
suspend fun playPlaylist(playlist: PlaylistRef) { suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch { viewModelScope.launch {
@@ -366,9 +361,9 @@ class HomeViewModel @Inject constructor(
fun retry() = veil.request(userInitiated = true) fun retry() = veil.request(userInitiated = true)
/** /**
* Manual pull-to-refresh. Goes behind the veil like every other refresh * Manual pull-to-refresh. Goes behind the veil like every other
* (operator call, 2026-07-31: the churn a pull causes is identical to * refresh: a pull churns the screen exactly as the automatic paths
* the automatic paths, and a small spinner didn't hide it). * do, and a small spinner does not hide it.
* *
* Suspends until the veil has taken over OR the session has finished, * Suspends until the veil has taken over OR the session has finished,
* so the pull indicator hands off to exactly one successor: the veil if * so the pull indicator hands off to exactly one successor: the veil if
@@ -488,7 +483,7 @@ class HomeViewModel @Inject constructor(
* Only user-initiated sessions say anything. The same outcome from a * Only user-initiated sessions say anything. The same outcome from a
* background check — the initial load, the 03:00 rebuild, a reconnect — * background check — the initial load, the 03:00 rebuild, a reconnect —
* is noise, and "Already up to date" on every launch would be worse * is noise, and "Already up to date" on every launch would be worse
* than silence (operator's call, 2026-07-31). * than silence.
*/ */
private fun reportRefreshOutcome(result: VeilSessionResult) { private fun reportRefreshOutcome(result: VeilSessionResult) {
if (!result.userInitiated) return if (!result.userInitiated) return
@@ -505,7 +500,7 @@ class HomeViewModel @Inject constructor(
* *
* Raised only when a refresh actually changes what's on screen, and then * Raised only when a refresh actually changes what's on screen, and then
* held until Home settles — tiles hydrated, artwork loaded — instead of * held until Home settles — tiles hydrated, artwork loaded — instead of
* for a fixed delay after the network pull returns (issue #2327). A * for a fixed delay after the network pull returns. A
* refresh that returns what's already cached shows no veil at all; * refresh that returns what's already cached shows no veil at all;
* [reportRefreshOutcome] tells the user instead, if they asked. * [reportRefreshOutcome] tells the user instead, if they asked.
*/ */
@@ -515,9 +510,9 @@ class HomeViewModel @Inject constructor(
// Every refresh path goes through the controller, which decides // Every refresh path goes through the controller, which decides
// per session whether to raise the veil. That includes the initial // per session whether to raise the veil. That includes the initial
// load: over a warm cache it's a full re-pull that churns every // load: over a warm cache it's a full re-pull that churns every
// section, and it used to run completely unveiled. // section.
veil.request() veil.request()
// Screen-level auto-recovery (issue #1245): a Home that failed to // Screen-level auto-recovery: a Home that failed to
// load while the server was unreachable re-pulls itself the moment // load while the server was unreachable re-pulls itself the moment
// health returns — same idiom as SyncController, one layer up. // health returns — same idiom as SyncController, one layer up.
// This is also the recovery that keeps trying after the veil has // This is also the recovery that keeps trying after the veil has
@@ -525,9 +520,8 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { veil.request() } networkStatus.recoveries().collect { veil.request() }
} }
// Server-side changes that rewrite what Home renders (#968 and // Server-side changes that rewrite what Home renders re-pull
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the // behind the veil, as the web SSE consumer does.
// web SSE consumer.
viewModelScope.launch { viewModelScope.launch {
eventsStream.events eventsStream.events
.filter { it.kind in VEILED_EVENT_KINDS } .filter { it.kind in VEILED_EVENT_KINDS }
@@ -758,8 +752,7 @@ private fun HomeSuccessContent(
// Always rendered: real system/user playlists, with // Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover slots // placeholder cards filling the For You / Discover slots
// that haven't generated yet. When offline, the cache-backed // that haven't generated yet. When offline, the cache-backed
// pool cards lead the row. Songs-like now lives in its own // pool cards lead the row. Songs-like has its own row below.
// dedicated row below (#1491), no longer inside this carousel.
PlaylistsRow( PlaylistsRow(
title = "Playlists", title = "Playlists",
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline), rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
@@ -769,10 +762,9 @@ private fun HomeSuccessContent(
onPlayPlaylist = onPlayPlaylist, onPlayPlaylist = onPlayPlaylist,
) )
} }
// Songs-like is the best-performing surface (#1491) — promoted out // Songs-like gets its own row so it can show a wider spread of
// of the Playlists carousel into its own row so it shows a wider // "Songs like {artist}" mixes than a carousel slot allows. Hidden
// spread of "Songs like {artist}" mixes. Hidden when there's // when there's nothing to show (offline with none cached).
// nothing to show (offline with none cached).
songsLikeSection( songsLikeSection(
playlists = sections.playlists, playlists = sections.playlists,
status = systemStatus, status = systemStatus,
@@ -805,11 +797,11 @@ private fun HomeSuccessContent(
} }
/** /**
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card * The dedicated "Songs like…" row. Reuses [PlaylistsRow]'s card
* rendering with a distinct title; hidden entirely when there's nothing * rendering with a distinct title; hidden entirely when there's nothing
* to show (offline with no cached mixes). Online with none generated yet * to show (offline with no cached mixes). Online with none generated yet
* still shows a few placeholders so the building / seed-needed state is * still shows a few placeholders so the building / seed-needed state is
* visible, matching the pre-promotion carousel behavior. * visible.
*/ */
private fun LazyListScope.songsLikeSection( private fun LazyListScope.songsLikeSection(
playlists: List<PlaylistRef>, playlists: List<PlaylistRef>,
@@ -1000,7 +992,7 @@ private fun EmptySection(title: String, body: String) {
/** /**
* Rediscover may have both an albums sub-row and an artists sub-row. When * Rediscover may have both an albums sub-row and an artists sub-row. When
* both are present the artist row gets an empty title so the section * both are present the artist row gets an empty title so the section
* reads as a single visual block — matches the Flutter behavior. * reads as a single visual block.
*/ */
@Composable @Composable
private fun RediscoverBlock( private fun RediscoverBlock(
@@ -1164,18 +1156,12 @@ enum class OfflinePoolKind(val label: String) {
* placeholder otherwise), then the secondary system kinds (deep cuts / * placeholder otherwise), then the secondary system kinds (deep cuts /
* rediscover / new for you / on this day / first listens) when they exist — * rediscover / new for you / on this day / first listens) when they exist —
* no placeholders for these since they're conditional on library shape — then * no placeholders for these since they're conditional on library shape — then
* user-owned playlists. Songs-like has its own dedicated row (#1491) via * user-owned playlists. Songs-like has its own row via [buildSongsLikeRow].
* [buildSongsLikeRow] and no longer appears in this carousel.
* *
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the * Offline: the two cache-backed pools (Recently played, Liked) lead, then the
* same real playlists in curated order but stably partitioned fully-cached * same real playlists in curated order but stably partitioned fully-cached
* 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 (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.
*/ */
internal fun buildPlaylistsRow( internal fun buildPlaylistsRow(
owned: List<PlaylistRef>, owned: List<PlaylistRef>,
@@ -1204,7 +1190,7 @@ private fun buildOnlineRow(
out += owned.firstOrNull { it.systemVariant == "discover" } out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status)) ?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
// Songs-like is no longer here — it has its own dedicated row (#1491). // Songs-like is not here: it has its own row.
for (variant in SECONDARY_SYSTEM_VARIANTS) { for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) } owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
} }
@@ -1213,8 +1199,8 @@ private fun buildOnlineRow(
} }
/** /**
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}" * Builds the dedicated Songs-like row: every "Songs like {artist}" mix
* mixes the server generated, no longer capped to the 3 carousel slots. * the server generated, uncapped.
* *
* Online: every generated mix as a real card; when none exist yet, a few * Online: every generated mix as a real card; when none exist yet, a few
* placeholders so the building / seed-needed state stays visible. * placeholders so the building / seed-needed state stays visible.
@@ -1244,7 +1230,7 @@ internal fun buildSongsLikeRow(
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and * Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
* only differs by dropping placeholders + partitioning available-first. * only differs by dropping placeholders + partitioning available-first.
* Songs-like is excluded here too — it renders in its own row via * Songs-like is excluded here too — it renders in its own row via
* [buildSongsLikeRow] in both online and offline modes (#1491). * [buildSongsLikeRow] in both online and offline modes.
*/ */
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> { private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
val out = mutableListOf<PlaylistRef>() val out = mutableListOf<PlaylistRef>()
@@ -1266,7 +1252,7 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
// How many "Songs like…" placeholder cards the dedicated row shows while // How many "Songs like…" placeholder cards the dedicated row shows while
// the mixes haven't generated yet (building / seed-needed). Real mixes, // the mixes haven't generated yet (building / seed-needed). Real mixes,
// once generated, are shown in full and no longer capped by this (#1491). // once generated, are shown in full, uncapped.
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3 private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
/** /**
@@ -1308,8 +1294,7 @@ private fun ArtistsRow(
/** /**
* Most-played section. Renders compact track tiles (cover + title + artist). * Most-played section. Renders compact track tiles (cover + title + artist).
* Tapping a tile replaces the queue with the entire section starting * Tapping a tile replaces the queue with the entire section starting
* at that tile — mirrors Flutter's `_resolveSectionTracks` behaviour * at that tile — Home sections are themselves playable units.
* where Home sections are themselves playable units.
*/ */
@Composable @Composable
private fun MostPlayedRow( private fun MostPlayedRow(
@@ -1373,12 +1358,8 @@ private const val MOST_PLAYED_TILE_HEIGHT_DP = 56
private const val MOST_PLAYED_COVER_DP = 48 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. Horizontal-row cards, much denser than the square
// the Flutter client) which // per-track tiles web uses, so more tracks fit in less space.
// 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
// them could fit in a smaller space").
private const val MOST_PLAYED_GRID_HEIGHT_DP = 200 private const val MOST_PLAYED_GRID_HEIGHT_DP = 200
@Composable @Composable
@@ -23,10 +23,9 @@ import javax.inject.Singleton
* server and upsert into Room — the Flow emissions then propagate * server and upsert into Room — the Flow emissions then propagate
* automatically. * automatically.
* *
* Constructs its own LibraryApi from the shared Retrofit. Per the * Constructs its own LibraryApi from the shared Retrofit: repositories
* "repositories own their interfaces" pattern adopted in Phase 5.1's * own their interfaces (see NetworkModule), so there is no @Provides
* NetworkModule, we don't @Provides per-endpoint Retrofit interfaces * per endpoint — fewer Hilt bindings, locality of reference.
* — fewer Hilt bindings, locality of reference.
*/ */
// LibraryRepository owns reads + refreshes for three entity families // LibraryRepository owns reads + refreshes for three entity families
// (artists / albums / tracks) plus shuffle. Function count scales with // (artists / albums / tracks) plus shuffle. Function count scales with
@@ -46,8 +45,7 @@ class LibraryRepository @Inject constructor(
// Joins each artist to its first cached album (by sort title) so the // Joins each artist to its first cached album (by sort title) so the
// tile cover derives from /api/albums/{id}/cover — cached_artists // tile cover derives from /api/albums/{id}/cover — cached_artists
// stores no cover and sync carries none. Mirrors Flutter's // stores no cover and sync carries none.
// artistTileProvider JOIN.
fun observeArtists(): Flow<List<ArtistRef>> = fun observeArtists(): Flow<List<ArtistRef>> =
combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums -> combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums ->
val firstAlbumByArtist = albums val firstAlbumByArtist = albums
@@ -161,13 +159,12 @@ class LibraryRepository @Inject constructor(
suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> = suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> =
api.shuffleLibrary(limit = limit).map { it.toDomain() } api.shuffleLibrary(limit = limit).map { it.toDomain() }
// ---- Browse axes (#367 / #2467) ---- // ---- Browse axes ----
// //
// Server-backed rather than cache-first, unlike everything above. The // Server-backed rather than cache-first, unlike everything above: the
// cache mirrors the whole library but includes tracks whose files are // server's index leaves out tracks whose files are missing and is the
// missing, with no flag to spot them (#2704), while the server's index // one the web client shows. See LibraryApi for why the cache's own
// excludes them -- so a locally-derived index would quietly disagree with // missing mark is not used to build it locally.
// the web client's. Revisit when #2704 lands.
/** Genre index, ordered by track count then name (server order). */ /** Genre index, ordered by track count then name (server order). */
suspend fun genres(): List<GenreCount> = suspend fun genres(): List<GenreCount> =
@@ -67,7 +67,7 @@ class AlbumDetailViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): a detail that failed to // Screen-level auto-recovery: a detail that failed to
// load while the server was unreachable re-fetches when health // load while the server was unreachable re-fetches when health
// returns, so coming back to the screen shows content, not an error. // returns, so coming back to the screen shows content, not an error.
viewModelScope.launch { viewModelScope.launch {
@@ -310,9 +310,9 @@ private fun ArtistHeader(
@Composable @Composable
private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) { private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) {
// Server coverUrl wins; otherwise mirror the server's "use the // Server coverUrl wins; otherwise use the first loaded album's
// first album's cover" rule via the loaded album list (Flutter // cover, the same rule the server applies. Bare Lucide.User only
// _ArtistAvatar). Bare Lucide.User only when neither exists. // when neither exists.
val model = when { val model = when {
artist.coverUrl.isNotEmpty() -> artist.coverUrl artist.coverUrl.isNotEmpty() -> artist.coverUrl
fallbackAlbumId != null -> albumCoverPath(fallbackAlbumId) fallbackAlbumId != null -> albumCoverPath(fallbackAlbumId)
@@ -72,7 +72,7 @@ class ArtistDetailViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245). From Error, redo the // Screen-level auto-recovery. From Error, redo the
// whole load; from Success, re-pull just the secondary sections — // whole load; from Success, re-pull just the secondary sections —
// their fetch failures are swallowed to emptyList, so a dead zone // their fetch failures are swallowed to emptyList, so a dead zone
// during the first load leaves them permanently absent otherwise. // during the first load leaves them permanently absent otherwise.
@@ -139,10 +139,9 @@ class ArtistDetailViewModel @Inject constructor(
/** /**
* Pull every track across the artist's albums, shuffle, and start * Pull every track across the artist's albums, shuffle, and start
* playing. Mirrors Flutter's artist Play button — pressing Play on * playing. Pressing Play on an artist means "shuffle this artist",
* an artist is implicitly "shuffle this artist", not "play album by * not "play album by album in tracklist order". One network
* album in tracklist order". Network round-trip per click; fine * round-trip per click; fine because the user explicitly tapped Play.
* because the user explicitly tapped Play.
*/ */
fun playArtist() { fun playArtist() {
viewModelScope.launch { viewModelScope.launch {
@@ -42,15 +42,12 @@ data class AlbumBrowseState(
} }
/** /**
* Backs the Genres and Years tabs (#2467), mirroring the web surfaces #367 * Backs the Genres and Years tabs, the same browse axes the web client has.
* shipped.
* *
* Both indexes come from the server, which is a deliberate departure from the * Both indexes come from the server, unlike the cache-first Artists/Albums
* cache-first Artists/Albums tabs beside them: the local cache includes tracks * tabs beside them: the server's index leaves out tracks whose files are
* whose files are missing and cannot tell you which (#2704), while the server's * missing and matches the web client's. These two tabs therefore need a
* index excludes them, so a locally-derived index would disagree with the web * connection; the empty states say so rather than looking broken.
* client's. These two tabs therefore need a connection; the empty states say so
* rather than looking broken.
*/ */
// Two browse axes, each with an index, a filter/sort or grouping, a // Two browse axes, each with an index, a filter/sort or grouping, a
// drill-down and a pager. The function count is two axes' worth of a // drill-down and a pager. The function count is two axes' worth of a
@@ -41,7 +41,7 @@ import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
/** /**
* Genres tab (#2467) — the Android half of the browse axis #367 shipped on web. * Genres tab: the genre browse axis, as on web.
* *
* Two states in one tab rather than a navigation destination: the index, and * Two states in one tab rather than a navigation destination: the index, and
* the albums for a chosen genre. Back returns to the index. A route would have * the albums for a chosen genre. Back returns to the index. A route would have
@@ -54,17 +54,11 @@ 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
* the Flutter client.
* *
* Artists + Albums are wired against the existing LibraryViewModel * Artists + Albums are cache-first reads of cached_artists /
* (cache-first reads of cached_artists / cached_albums). The other * cached_albums through LibraryViewModel; Genres and Years read the
* three tabs render placeholder EmptyStates pointing at the upcoming * server through BrowseViewModel; History, Liked and Hidden are their
* data-layer phases: * own tabs ([HistoryTab], [LikedTab], [HiddenTab]).
* - History → Phase 9 (server-side /api/me/history wiring)
* - Liked → Phase 8 (likes write path + cached_likes hydration)
* - Hidden → Phase 10 (quarantine flag/unflag UI lives in Admin slice
* but the user-side "my hidden tracks" view comes here)
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -160,7 +154,7 @@ private const val TAB_LIKED = 5
private const val TAB_HIDDEN = 6 private const val TAB_HIDDEN = 6
// Genres and Years sit straight after Albums, matching the web tab bar's // Genres and Years sit straight after Albums, matching the web tab bar's
// order (#2467) -- they are browse axes over the same albums, so they belong // order -- they are browse axes over the same albums, so they belong
// beside them rather than after the personal tabs. // beside them rather than after the personal tabs.
private val LIBRARY_TABS = private val LIBRARY_TABS =
listOf("Artists", "Albums", "Genres", "Years", "History", "Liked", "Hidden") listOf("Artists", "Albums", "Genres", "Years", "History", "Liked", "Hidden")
@@ -26,7 +26,7 @@ import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
/** /**
* Years tab (#2467). Same two-state shape as [GenresTab]: the decade-grouped * Years tab. Same two-state shape as [GenresTab]: the decade-grouped
* index, then the albums for a chosen year. * index, then the albums for a chosen year.
* *
* Albums with no release date are absent from this axis entirely — the server * Albums with no release date are absent from this axis entirely — the server
@@ -25,16 +25,15 @@ import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
/** /**
* One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp * One album tile (~176dp wide, 144dp square cover). Tap fires
* square cover). Tap fires `onClick` — wired to `AlbumDetail` nav in * `onClick`. The tile stashes its `album` in [LocalDetailSeedCache]
* Phase 5.5. The tile stashes its `album` in [LocalDetailSeedCache]
* before firing onClick so the detail screen's AppBar renders title / * before firing onClick so the detail screen's AppBar renders title /
* cover / counts immediately without waiting for the fetch. * cover / counts immediately without waiting for the fetch.
* *
* [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp * [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp
* circular play button is overlaid at the bottom-right of the cover * circular play button is overlaid at the bottom-right of the cover.
* (mirrors Flutter's PlayCircleButton). Only Home wires it currently; * Only Home wires it currently; Library / Discover / detail surfaces
* Library / Discover / detail surfaces leave it null. * leave it null.
*/ */
@Composable @Composable
fun AlbumCard( fun AlbumCard(
@@ -28,15 +28,13 @@ import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
/** /**
* One artist tile. Circular cover; name centered beneath. Tap fires * One artist tile. Circular cover; name centered beneath. Tap fires
* `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the * `onClick`. Stashes the `artist` in [LocalDetailSeedCache] on click
* `artist` in [LocalDetailSeedCache] on click so the ArtistDetail * so the ArtistDetail AppBar renders the name before the fetch lands.
* AppBar renders the name before the fetch lands.
* *
* [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp * [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp
* circular play button is overlaid at the bottom-right of the avatar * circular play button is overlaid at the bottom-right of the avatar.
* (mirrors Flutter's PlayCircleButton). Only Home wires it currently. * Only Home wires it currently. Padding is 4dp instead of 6dp because
* Padding is 4dp instead of 6dp because the circular avatar pulls the * the circular avatar pulls the button visually inward at the corner.
* button visually inward at the corner.
*/ */
@Composable @Composable
fun ArtistCard( fun ArtistCard(
@@ -24,26 +24,21 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Cache-first reads + offline-first writes for likes. Mirrors the * Cache-first reads + offline-first writes for likes.
* Flutter `likesControllerProvider` + `cached_likes`-driven Liked tab
* pattern.
* *
* Local `userId` discriminator is the server-side user UUID * The local `userId` discriminator is the server-side user UUID
* (resolved via [AuthController.currentUser]); falls back to * (resolved via [AuthController.currentUser]); it falls back to
* [ANONYMOUS_USER_ID] when no user is signed in so existing local * [ANONYMOUS_USER_ID] when no user is signed in, so rows in that bucket
* cache rows from pre-#576 builds remain queryable until the first * stay queryable until the first authenticated [refreshIds] call
* authenticated [refreshIds] call overwrites them. On user-switch * overwrites them. On user-switch (sign-out / sign-in as a different
* (sign-out / sign-in as different user) the OUTGOING user's * user) the OUTGOING user's cached_likes rows are wiped so they don't
* cached_likes rows are wiped so they don't leak into the new * leak into the new session.
* session — drift #576 audit caught the leak.
* *
* Write path follows `feedback_offline_first_for_server_writes` — * Writes never go fire-and-forget:
* never fire-and-forget:
* 1. Optimistic Room mutation (UI flips instantly). * 1. Optimistic Room mutation (UI flips instantly).
* 2. Best-effort server call. * 2. Best-effort server call.
* 3. On exception → enqueue in MutationQueue for the Phase 12.2 * 3. On exception → enqueue in MutationQueue for the replayer to
* replayer to drain when connectivity returns. * drain when connectivity returns.
*
* Reads expose Flows of hydrated DomainRefs. Per-tile hydration goes * Reads expose Flows of hydrated DomainRefs. Per-tile hydration goes
* through the existing Library DAOs — IDs without cached entity rows * through the existing Library DAOs — IDs without cached entity rows
* are silently dropped (the SyncController fills the gap async). * are silently dropped (the SyncController fills the gap async).
@@ -62,12 +57,10 @@ class LikesRepository @Inject constructor(
private val api: LikesApi = retrofit.create() private val api: LikesApi = retrofit.create()
init { init {
// Drift #576: clear the outgoing user's cached_likes rows // Clear the outgoing user's cached_likes rows when the signed-in
// when the signed-in user changes. Mostly a hygiene fix — // user changes. Discriminator-based queries already keep the wrong
// discriminator-based queries already prevent the wrong // user from SEEING them; without this the rows pile up forever as
// user from SEEING leaked rows, but without this the rows // different accounts sign in/out on the same device.
// pile up forever as different accounts sign in/out on the
// same device.
scope.launch { scope.launch {
var previousId: String? = null var previousId: String? = null
authController.currentUser.collect { user -> authController.currentUser.collect { user ->
@@ -138,7 +131,7 @@ class LikesRepository @Inject constructor(
*/ */
suspend fun toggleLike(entityType: String, entityId: String, desiredState: Boolean): Boolean { suspend fun toggleLike(entityType: String, entityId: String, desiredState: Boolean): Boolean {
// 1. Optimistic Room mutation — scoped to the signed-in user // 1. Optimistic Room mutation — scoped to the signed-in user
// (or the legacy "local" fallback for pre-#576 rows). // (or the "local" bucket when nobody is signed in).
val uid = currentUserId() val uid = currentUserId()
if (desiredState) { if (desiredState) {
likeDao.upsertAll( likeDao.upsertAll(
@@ -157,8 +150,7 @@ class LikesRepository @Inject constructor(
) { ) {
// Intentional swallow: the queue replays this later; surfacing // Intentional swallow: the queue replays this later; surfacing
// the network error here would force callers (UI) to handle it // the network error here would force callers (UI) to handle it
// even though it's already been buffered. The replayer logs // even though it's already been buffered.
// per-attempt diagnostics when it lands in Phase 12.2.
mutationQueue.enqueueLikeToggle(entityType, entityId, desiredState) mutationQueue.enqueueLikeToggle(entityType, entityId, desiredState)
false false
} }
@@ -166,17 +158,14 @@ class LikesRepository @Inject constructor(
/** /**
* Pulls `GET /api/likes/ids` and atomically replaces the user's * Pulls `GET /api/likes/ids` and atomically replaces the user's
* cached_likes set with the server's canonical view. Called by * cached_likes set with the server's canonical view. Called by the
* the LikedTab ViewModel on init (and by the SyncController in * LikedTab ViewModel on init and on a live likes event.
* Phase 12).
* *
* Drift #570 fix: previously this called `upsertAll` only, which * A replace, not an upsert: upserts add rows for IDs the server has
* added rows for IDs the server had but never removed rows the * but never remove the ones it dropped, so an unlike made on another
* server no longer surfaced — so a cross-device unlike (user * device would stay on the Liked tab forever. The replaceAllForUser
* likes on web, then unlikes on web) left the Liked tab on * DAO method runs a transactional delete-then-insert so the local
* Android showing the now-unliked entry forever. The atomic * set is exactly what the server reports.
* replaceAllForUser DAO method runs a transactional delete-then-
* insert so the local set is exactly what the server reports.
*/ */
suspend fun refreshIds() { suspend fun refreshIds() {
val uid = currentUserId() val uid = currentUserId()
@@ -189,13 +178,10 @@ class LikesRepository @Inject constructor(
} }
companion object { companion object {
// Pre-auth fallback discriminator. Drift #576: when no user is // Fallback discriminator for when no user is signed in. Reads
// signed in OR for cached_likes rows written by pre-#576 // query this bucket until the first authenticated refreshIds()
// builds (which all tagged "local"), reads continue to query // writes rows under the real user UUID; reads then switch over
// this bucket so the Liked tab isn't suddenly empty after the // via currentUserId().
// upgrade. The first authenticated refreshIds() call writes
// rows under the real user UUID; reads then transparently
// switch over via currentUserId().
const val ANONYMOUS_USER_ID: String = "local" const val ANONYMOUS_USER_ID: String = "local"
const val ENTITY_ARTIST: String = "artist" const val ENTITY_ARTIST: String = "artist"
const val ENTITY_ALBUM: String = "album" const val ENTITY_ALBUM: String = "album"
@@ -81,7 +81,7 @@ class LikedTabViewModel @Inject constructor(
init { init {
refresh() refresh()
// Screen-level auto-recovery (issue #1245): re-pull when server // Screen-level auto-recovery: re-pull when server
// health returns, so a load that failed offline heals unprompted. // health returns, so a load that failed offline heals unprompted.
viewModelScope.launch { viewModelScope.launch {
networkStatus.recoveries().collect { refresh() } networkStatus.recoveries().collect { refresh() }
@@ -122,8 +122,8 @@ class LikedTabViewModel @Inject constructor(
/** /**
* Build a queue from the currently-displayed liked tracks and start * Build a queue from the currently-displayed liked tracks and start
* playback at [initialIndex]. Mirrors Flutter's tap-on-liked-track * playback at [initialIndex]: the visible track list becomes the
* behavior: the visible track list becomes the play queue. Tracks * play queue. Tracks
* with empty streamUrl are ignored as queue entries (the row that * with empty streamUrl are ignored as queue entries (the row that
* routes here is itself click-gated on streamUrl). * routes here is itself click-gated on streamUrl).
*/ */
@@ -7,11 +7,11 @@ import javax.inject.Singleton
private const val TOP_N = 8 private const val TOP_N = 8
/** /**
* Pre-warms the Room cache for likely-tap artist tiles on Home. Mirrors * Pre-warms the Room cache for likely-tap artist tiles on Home.
* Flutter's `MetadataPrefetcher`: conservative — artists only (a single * Conservative: artists only (a single round-trip per id), top-8 per
* round-trip per id), top-8 per call, and once per id per session so * call, and once per id per session so repeated index refreshes don't
* repeated index refreshes don't re-fetch. Albums are intentionally not * re-fetch. Albums are intentionally not pre-warmed (their detail fetch
* pre-warmed (their detail fetch fans out a track list). * fans out a track list).
* *
* Fetches go through [MetadataProvider.refreshArtist], so they share the * Fetches go through [MetadataProvider.refreshArtist], so they share the
* same per-id dedup AND the 4-slot concurrency gate as on-render * same per-id dedup AND the 4-slot concurrency gate as on-render
@@ -29,10 +29,8 @@ private const val HTTP_NOT_FOUND = 404
/** /**
* Cache-first metadata reads with eager on-miss live-fetch. * Cache-first metadata reads with eager on-miss live-fetch.
* *
* Mirrors Flutter's tile-provider pattern (`albumTileProvider(id)` / * Subscribers observe a per-ID Flow that emits the Room row immediately
* `artistTileProvider(id)` / `trackTileProvider(id)`): subscribers * if present, and otherwise triggers a single background fetch that
* observe a per-ID Flow that emits the Room row immediately if
* present, and otherwise triggers a single background fetch that
* upserts into Room — the flow then re-emits the populated row. * upserts into Room — the flow then re-emits the populated row.
* *
* Fetch dedup is per-ID via a ConcurrentHashMap of in-flight Jobs; * Fetch dedup is per-ID via a ConcurrentHashMap of in-flight Jobs;
@@ -57,9 +55,8 @@ class MetadataProvider @Inject constructor(
private val inFlightTracks = ConcurrentHashMap<String, Job>() private val inFlightTracks = ConcurrentHashMap<String, Job>()
// Caps concurrent on-miss fetches so a cold Home (~30 tiles) doesn't // Caps concurrent on-miss fetches so a cold Home (~30 tiles) doesn't
// fire ~30 parallel /api calls. Mirrors Flutter HydrationQueue's // fire ~30 parallel /api calls. Per-id dedup (above) is orthogonal:
// _maxConcurrent = 4. Per-id dedup (above) is orthogonal: the gate // the gate bounds distinct-id fetches; extras suspend on the semaphore.
// bounds distinct-id fetches; extras suspend on the semaphore.
private val fetchGate = Semaphore(MAX_CONCURRENT_FETCHES) private val fetchGate = Semaphore(MAX_CONCURRENT_FETCHES)
/** /**
@@ -123,7 +120,7 @@ class MetadataProvider @Inject constructor(
* [onGone] runs when the server answers 404: the row no longer exists * [onGone] runs when the server answers 404: the row no longer exists
* there (a duplicate merge, a deleted file), so the cached copy is * there (a duplicate merge, a deleted file), so the cached copy is
* dropped. Kept, it stays stale forever and [FreshnessSweeper] asks for * dropped. Kept, it stays stale forever and [FreshnessSweeper] asks for
* it every pass (#5654). Any other failure leaves the row for a retry. * it every pass. Any other failure leaves the row for a retry.
*/ */
private fun launchDeduped( private fun launchDeduped(
inFlight: ConcurrentHashMap<String, Job>, inFlight: ConcurrentHashMap<String, Job>,
@@ -14,7 +14,7 @@ data class AdminQuarantineReportRef(
/** /**
* Aggregated quarantine row in the admin queue — all reports against a * Aggregated quarantine row in the admin queue — all reports against a
* single track collapsed into per-reason counts plus the underlying * single track collapsed into per-reason counts plus the underlying
* per-user reports. Mirrors Flutter's `AdminQuarantineItem`. * per-user reports.
* *
* `topReasonSummary` returns the most-cited reason, with a "(+N more)" * `topReasonSummary` returns the most-cited reason, with a "(+N more)"
* suffix when other distinct reasons exist. * suffix when other distinct reasons exist.
@@ -1,6 +1,6 @@
package com.fabledsword.minstrel.models package com.fabledsword.minstrel.models
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */ /** Domain model for one tag-enrichment provider in the admin surface. */
data class AdminTagSourceRef( data class AdminTagSourceRef(
val id: String, val id: String,
val displayName: String, val displayName: String,
@@ -1,7 +1,7 @@
package com.fabledsword.minstrel.models package com.fabledsword.minstrel.models
/** /**
* One admin-side user row. Mirrors Flutter's `AdminUser`. The * One admin-side user row. The
* admin-scoped variant exposes `autoApproveRequests` and the * admin-scoped variant exposes `autoApproveRequests` and the
* canonical `createdAt` that the user-scoped /me response omits. * canonical `createdAt` that the user-scoped /me response omits.
*/ */

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