Compare commits
90
Commits
@@ -80,10 +80,16 @@ jobs:
|
||||
|
||||
- name: Upload debug APK
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Gitea Actions runs in GHES-emulation mode; @actions/artifact v2+
|
||||
# (i.e. upload-artifact@v4+) errors with "GHESNotSupportedError".
|
||||
# Pin to @v3 until act_runner or the artifact backend catches up.
|
||||
uses: actions/upload-artifact@v3
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||
# serves artifacts back only through the v4 API, so the upload is stored
|
||||
# and invisible to every retrieval path. @v3 is what left 72 unreachable
|
||||
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
|
||||
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
|
||||
# See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: minstrel-android-debug-${{ github.sha }}
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -131,12 +131,19 @@ jobs:
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# @v3 because Gitea Actions emulates GHES and the v2 artifact
|
||||
# backend used by upload-artifact@v4 errors with GHESNotSupportedError.
|
||||
uses: actions/upload-artifact@v3
|
||||
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||
# the producing half of a pair: image-release downloads `minstrel-apk`
|
||||
# below with the matching download-artifact mirror. Both must stay on
|
||||
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
||||
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
# error, not the default warn: image-release hard-depends on this
|
||||
# artifact existing, so an empty upload must fail here, not there.
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach APK to gitea Release
|
||||
shell: bash
|
||||
@@ -238,7 +245,20 @@ jobs:
|
||||
# Tag pushes only — android-release just produced this. Non-tag
|
||||
# builds take the "Bundle latest release APK" path below instead.
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/download-artifact@v3
|
||||
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||
# same reason: upstream's client-side GHES check rejects this hostname
|
||||
# before it connects. bvandeusen/download-artifact mirrors
|
||||
# code.forgejo.org/forgejo/download-artifact.
|
||||
#
|
||||
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
|
||||
# the action's own version number — the two actions release on unrelated
|
||||
# cadences, and download v5 would pair a ^2.3.2 client with this file's
|
||||
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
|
||||
# the same one proven against this instance by the upload side.
|
||||
# Deliberately NOT v7: it moves to node24 and upstream requires runner
|
||||
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
|
||||
# Pinned, not tagged — the mirror auto-syncs every 8h.
|
||||
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: client/
|
||||
|
||||
@@ -4,7 +4,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
|
||||
> State and intelligence belong on the server, not the client.
|
||||
|
||||
<!-- TODO: screenshot of the home page -->
|
||||
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -13,7 +13,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356).
|
||||
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -24,10 +24,18 @@ services:
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
# Your music library. Point ./music at wherever your audio files
|
||||
# live. Mounted read-only — Minstrel never writes to your library.
|
||||
- ./music:/music:ro
|
||||
- minstrel-data:/data
|
||||
# Generated data: playlist cover collages, artist art, caches.
|
||||
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||
# sets to /app/data — keep this mount on /app/data or your cache
|
||||
# won't survive a container recreate.
|
||||
- minstrel-data:/app/data
|
||||
environment:
|
||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||
# Colon-separated library roots to scan; must match the container
|
||||
# path of the read-only music mount above (/music here).
|
||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||
depends_on: [db]
|
||||
|
||||
@@ -37,6 +45,8 @@ services:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: minstrel
|
||||
POSTGRES_DB: minstrel
|
||||
# Postgres data dir — users, likes, play history, sessions, settings.
|
||||
# The one volume you must never lose; back it up with pg_dump.
|
||||
volumes: [pgdata:/var/lib/postgresql/data]
|
||||
|
||||
volumes:
|
||||
@@ -48,7 +58,29 @@ volumes:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
|
||||
## First run
|
||||
|
||||
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
|
||||
|
||||
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
|
||||
|
||||
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
||||
|
||||
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
|
||||
|
||||
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
||||
|
||||
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
|
||||
|
||||
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
|
||||
|
||||
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
|
||||
|
||||
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The "Install the Android app" download card in Settings"></a>
|
||||
|
||||
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
|
||||
|
||||
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
|
||||
|
||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||
|
||||
@@ -57,7 +89,7 @@ For the full configuration surface, see [`config.example.yaml`](./config.example
|
||||
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||
|
||||
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
||||
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
|
||||
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||
|
||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||
@@ -66,8 +98,13 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
## Updating
|
||||
|
||||
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.
|
||||
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
|
||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||
|
||||
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
|
||||
## Specs
|
||||
|
||||
|
||||
@@ -210,4 +210,17 @@ dependencies {
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
tasks.withType<Test> { useJUnitPlatform() }
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
// Print the assertion message + full stack trace for failures. The
|
||||
// default console output gives only "AssertionError at Foo.kt:12", and
|
||||
// for a failure inside a `runTest { }` lambda even that line collapses
|
||||
// to the test function's own line (the assertion frames live in the
|
||||
// suspend-lambda class, which Gradle filters out) — leaving nothing to
|
||||
// debug from when the HTML report isn't reachable, as in CI.
|
||||
testLogging {
|
||||
events("failed")
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
showStackTraces = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import androidx.work.Configuration
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.fabledsword.minstrel.cache.CacheIndexer
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.diagnostics.DiagnosticsReporter
|
||||
import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||
@@ -27,6 +30,10 @@ import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
// Cover-art fade-in. Coil skips the transition for memory-cache hits, so
|
||||
// already-loaded art still appears instantly — only a genuine fetch fades.
|
||||
private const val ART_CROSSFADE_MS = 220
|
||||
|
||||
@HiltAndroidApp
|
||||
class MinstrelApplication :
|
||||
Application(),
|
||||
@@ -155,6 +162,11 @@ class MinstrelApplication :
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
||||
|
||||
// Device diagnostics (M9). The reporter gates itself on the account's
|
||||
// debug flag; the uploader drains its buffer on a tick / recovery.
|
||||
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
|
||||
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
|
||||
|
||||
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -206,11 +218,18 @@ class MinstrelApplication :
|
||||
* OkHttp client as the network fetcher. The `callFactory` lambda
|
||||
* is invoked lazily so Hilt has time to inject `okHttpClient`
|
||||
* before Coil makes its first request.
|
||||
*
|
||||
* Crossfade is set here rather than per-call so every cover surface
|
||||
* in the app fades its artwork in instead of snapping it. Art
|
||||
* landing a beat after its tile was the most visible pop-in on Home
|
||||
* (issue #2327); `ServerImage` fades its placeholder out over the
|
||||
* same window so the two read as one cross-dissolve.
|
||||
*/
|
||||
override fun newImageLoader(context: android.content.Context): ImageLoader =
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
||||
}
|
||||
.crossfade(ART_CROSSFADE_MS)
|
||||
.build()
|
||||
}
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.fabledsword.minstrel.admin.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
|
||||
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
|
||||
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
||||
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Read-through accessor for the tag-enrichment provider settings (#1521).
|
||||
* No Room caching — admin settings are infrequent point-and-shoot edits;
|
||||
* mutations fire direct REST and the ViewModel reconciles on failure.
|
||||
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
|
||||
*/
|
||||
@Singleton
|
||||
class AdminTagSourcesRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: AdminTagSourcesApi = retrofit.create()
|
||||
|
||||
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
|
||||
|
||||
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
|
||||
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
|
||||
|
||||
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
|
||||
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
|
||||
|
||||
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
|
||||
}
|
||||
|
||||
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
|
||||
id = id,
|
||||
displayName = displayName,
|
||||
requiresApiKey = requiresApiKey,
|
||||
supports = supports,
|
||||
enabled = enabled,
|
||||
apiKeySet = apiKeySet,
|
||||
testable = testable,
|
||||
)
|
||||
|
||||
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
|
||||
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
|
||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.models.Invite
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -25,6 +27,7 @@ data class AdminInvitesUiState(
|
||||
@HiltViewModel
|
||||
class AdminInvitesViewModel @Inject constructor(
|
||||
private val repository: AdminInvitesRepository,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow(AdminInvitesUiState())
|
||||
@@ -36,6 +39,13 @@ class AdminInvitesViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value.message != null) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
|
||||
@@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.Inbox
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.composables.icons.lucide.TriangleAlert
|
||||
import com.composables.icons.lucide.Users
|
||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||
import com.fabledsword.minstrel.nav.AdminUsers
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -54,7 +57,7 @@ import javax.inject.Inject
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
|
||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
|
||||
|
||||
sealed interface AdminLandingUiState {
|
||||
data object Loading : AdminLandingUiState
|
||||
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
|
||||
private val requestsRepo: AdminRequestsRepository,
|
||||
private val quarantineRepo: AdminQuarantineRepository,
|
||||
private val usersRepo: AdminUsersRepository,
|
||||
private val tagSourcesRepo: AdminTagSourcesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
|
||||
val req = async { requestsRepo.list().size }
|
||||
val qua = async { quarantineRepo.list().size }
|
||||
val usr = async { usersRepo.list().size }
|
||||
AdminCounts(req.await(), qua.await(), usr.await())
|
||||
val tag = async { tagSourcesRepo.list().count { it.enabled } }
|
||||
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
|
||||
}
|
||||
internal.value = AdminLandingUiState.Success(counts)
|
||||
} catch (
|
||||
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
||||
onClick = { navController.navigate(AdminUsers) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SectionCard(
|
||||
icon = Lucide.Music,
|
||||
title = "Tag sources",
|
||||
subtitle = "Metadata enrichment providers",
|
||||
count = counts.tagSources,
|
||||
onClick = { navController.navigate(AdminTagSources) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -29,6 +29,7 @@ import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
@@ -62,9 +63,10 @@ fun AdminQuarantineScreen(
|
||||
body = "When users flag tracks as bad rips, wrong tags, or " +
|
||||
"duplicates, their reports get aggregated and surfaced here.",
|
||||
)
|
||||
is AdminQuarantineUiState.Error -> EmptyState(
|
||||
is AdminQuarantineUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load queue",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is AdminQuarantineUiState.Success -> QueueList(
|
||||
rows = s.rows,
|
||||
|
||||
+10
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -26,6 +28,7 @@ sealed interface AdminQuarantineUiState {
|
||||
class AdminQuarantineViewModel @Inject constructor(
|
||||
private val repository: AdminQuarantineRepository,
|
||||
private val eventsStream: EventsStream,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||
@@ -38,6 +41,13 @@ class AdminQuarantineViewModel @Inject constructor(
|
||||
.filter { it.kind.startsWith("quarantine.") }
|
||||
.collect { refresh() }
|
||||
}
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is AdminQuarantineUiState.Error) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
@@ -61,9 +62,10 @@ fun AdminRequestsScreen(
|
||||
body = "When users ask Lidarr for new music, their pending " +
|
||||
"requests show up here for approval.",
|
||||
)
|
||||
is AdminRequestsUiState.Error -> EmptyState(
|
||||
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load requests",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is AdminRequestsUiState.Success -> RequestList(
|
||||
rows = s.rows,
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -35,6 +37,7 @@ class AdminRequestsViewModel @Inject constructor(
|
||||
private val repository: AdminRequestsRepository,
|
||||
private val usersRepository: AdminUsersRepository,
|
||||
private val eventsStream: EventsStream,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
||||
@@ -47,6 +50,13 @@ class AdminRequestsViewModel @Inject constructor(
|
||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||
.collect { refresh() }
|
||||
}
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is AdminRequestsUiState.Error) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.fabledsword.minstrel.admin.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdminTagSourcesScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
title = "Admin · Tag sources",
|
||||
navController = navController,
|
||||
currentRouteName = AdminTagSources::class.qualifiedName,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
when (val s = state) {
|
||||
AdminTagSourcesUiState.Loading -> LoadingCentered()
|
||||
AdminTagSourcesUiState.Empty -> EmptyState(
|
||||
title = "No tag sources",
|
||||
body = "Tag-enrichment providers register on the server; none are available.",
|
||||
)
|
||||
is AdminTagSourcesUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load tag sources",
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is AdminTagSourcesUiState.Success -> TagSourceList(
|
||||
providers = s.providers,
|
||||
testResults = s.testResults,
|
||||
onToggle = viewModel::setEnabled,
|
||||
onSaveKey = viewModel::saveApiKey,
|
||||
onTest = viewModel::test,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagSourceList(
|
||||
providers: List<AdminTagSourceRef>,
|
||||
testResults: Map<String, TagSourceTestResult>,
|
||||
onToggle: (String, Boolean) -> Unit,
|
||||
onSaveKey: (String, String) -> Unit,
|
||||
onTest: (String) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(items = providers, key = { it.id }) { provider ->
|
||||
TagSourceCard(
|
||||
provider = provider,
|
||||
testResult = testResults[provider.id],
|
||||
onToggle = onToggle,
|
||||
onSaveKey = onSaveKey,
|
||||
onTest = onTest,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagSourceCard(
|
||||
provider: AdminTagSourceRef,
|
||||
testResult: TagSourceTestResult?,
|
||||
onToggle: (String, Boolean) -> Unit,
|
||||
onSaveKey: (String, String) -> Unit,
|
||||
onTest: (String) -> Unit,
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = provider.enabled,
|
||||
onCheckedChange = { onToggle(provider.id, it) },
|
||||
)
|
||||
}
|
||||
if (provider.requiresApiKey) {
|
||||
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
|
||||
}
|
||||
if (provider.testable) {
|
||||
TestRow(provider = provider, testResult = testResult, onTest = onTest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
|
||||
var key by remember(provider.id) { mutableStateOf("") }
|
||||
OutlinedTextField(
|
||||
value = key,
|
||||
onValueChange = { key = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("API key") },
|
||||
placeholder = {
|
||||
Text(
|
||||
if (provider.apiKeySet) {
|
||||
"••• saved — leave blank to keep"
|
||||
} else {
|
||||
"Paste your API key to enable this source"
|
||||
},
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
onSaveKey(provider.id, key)
|
||||
key = ""
|
||||
},
|
||||
enabled = key.isNotBlank(),
|
||||
) { Text("Save key") }
|
||||
if (provider.apiKeySet) {
|
||||
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestRow(
|
||||
provider: AdminTagSourceRef,
|
||||
testResult: TagSourceTestResult?,
|
||||
onTest: (String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
|
||||
testResult?.let { result ->
|
||||
if (result.ok) {
|
||||
Text(
|
||||
text = "OK (${result.durationMs}ms)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Failed — ${result.error}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.fabledsword.minstrel.admin.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface AdminTagSourcesUiState {
|
||||
data object Loading : AdminTagSourcesUiState
|
||||
data object Empty : AdminTagSourcesUiState
|
||||
data class Success(
|
||||
val providers: List<AdminTagSourceRef>,
|
||||
val testResults: Map<String, TagSourceTestResult>,
|
||||
) : AdminTagSourcesUiState
|
||||
data class Error(val message: String) : AdminTagSourcesUiState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class AdminTagSourcesViewModel @Inject constructor(
|
||||
private val repository: AdminTagSourcesRepository,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminTagSourcesUiState>(AdminTagSourcesUiState.Loading)
|
||||
val uiState: StateFlow<AdminTagSourcesUiState> = internal.asStateFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery: reload a failed list when server
|
||||
// health returns instead of waiting for a manual pull (issue #1245).
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is AdminTagSourcesUiState.Error) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
internal.value = AdminTagSourcesUiState.Loading
|
||||
try {
|
||||
val providers = repository.list()
|
||||
internal.value = if (providers.isEmpty()) {
|
||||
AdminTagSourcesUiState.Empty
|
||||
} else {
|
||||
AdminTagSourcesUiState.Success(providers, emptyMap())
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(id: String, enabled: Boolean) {
|
||||
val before = internal.value as? AdminTagSourcesUiState.Success ?: return
|
||||
// Optimistic toggle; reconcile via refresh() if the server rejects.
|
||||
internal.value = before.copy(
|
||||
providers = before.providers.map {
|
||||
if (it.id == id) it.copy(enabled = enabled) else it
|
||||
},
|
||||
)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.setEnabled(id, enabled)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveApiKey(id: String, apiKey: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
replaceProvider(repository.setApiKey(id, apiKey))
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun test(id: String) {
|
||||
viewModelScope.launch {
|
||||
val result = try {
|
||||
repository.test(id)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch
|
||||
internal.value = current.copy(testResults = current.testResults + (id to result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceProvider(updated: AdminTagSourceRef) {
|
||||
val current = internal.value as? AdminTagSourcesUiState.Success ?: return
|
||||
internal.value = current.copy(
|
||||
providers = current.providers.map { if (it.id == updated.id) updated else it },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.models.AdminUserRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -23,6 +25,7 @@ sealed interface AdminUsersUiState {
|
||||
@HiltViewModel
|
||||
class AdminUsersViewModel @Inject constructor(
|
||||
private val repository: AdminUsersRepository,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||
@@ -30,6 +33,13 @@ class AdminUsersViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is AdminUsersUiState.Error) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
||||
import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire
|
||||
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
|
||||
* surface for the tag-enrichment provider settings (#1490). Mirrors the
|
||||
* web integrations "Tag enrichment sources" card. Same shape as the
|
||||
* cover-sources admin surface; kept independent so a new tag source is
|
||||
* added without touching art settings.
|
||||
*/
|
||||
interface AdminTagSourcesApi {
|
||||
@GET("api/admin/tag-sources")
|
||||
suspend fun list(): AdminTagSourcesListWire
|
||||
|
||||
@PATCH("api/admin/tag-sources/{id}")
|
||||
suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire
|
||||
|
||||
@POST("api/admin/tag-sources/{id}/test")
|
||||
suspend fun test(@Path("id") id: String): TestTagSourceWire
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH body. Both fields are nullable + default-null so kotlinx omits the
|
||||
* untouched one (the server reads a missing/null field as "leave
|
||||
* unchanged"): send only `enabled` to toggle, only `apiKey` to set a key.
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateTagSourceBody(
|
||||
val enabled: Boolean? = null,
|
||||
@SerialName("api_key") val apiKey: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for device diagnostics ingest (M9). One call
|
||||
* 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,
|
||||
* which the uploader treats as success so it can drop the batch).
|
||||
*/
|
||||
interface DiagnosticsApi {
|
||||
@POST("api/diagnostics")
|
||||
suspend fun report(@Body body: DiagnosticsReportRequest)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class DiagnosticsReportRequest(
|
||||
@SerialName("client_id") val clientId: String,
|
||||
@SerialName("app_version") val appVersion: String? = null,
|
||||
@SerialName("os_version") val osVersion: String? = null,
|
||||
val events: List<DiagnosticEventWire>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DiagnosticEventWire(
|
||||
val kind: String,
|
||||
// Device-clock epoch milliseconds. The server stamps its own
|
||||
// received_at; both are stored so a skewed device clock is visible.
|
||||
@SerialName("occurred_at") val occurredAt: Long,
|
||||
// Opaque structured payload carrying the event sub-type + fields.
|
||||
val payload: JsonElement,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.auth
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.UserRef
|
||||
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
||||
@@ -35,6 +36,7 @@ class AuthController @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: AuthApi = retrofit.create()
|
||||
private val meApi: MeApi = retrofit.create()
|
||||
|
||||
private val currentUserState = MutableStateFlow<UserRef?>(null)
|
||||
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
|
||||
@@ -55,6 +57,10 @@ class AuthController @Inject constructor(
|
||||
currentUserState.value = raw?.let { decodeUser(it) }
|
||||
}
|
||||
}
|
||||
// Refresh from /api/me on startup so a remotely-changed account
|
||||
// flag (e.g. admin enabling debug mode, M9) reaches the device
|
||||
// without a re-login. Best-effort; failures keep the cached value.
|
||||
scope.launch { refreshProfile() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,9 +78,32 @@ class AuthController @Inject constructor(
|
||||
)
|
||||
currentUserState.value = user
|
||||
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
||||
// Pull the fuller /me shape (carries debug_mode_enabled) right
|
||||
// after login so the diagnostics gate is correct without waiting
|
||||
// for the next startup refresh.
|
||||
scope.launch { refreshProfile() }
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch the caller's profile from /api/me and update currentUser
|
||||
* + persisted userJson. Carries account-level flags (debug mode)
|
||||
* that aren't in the login response. Best-effort: a network failure
|
||||
* leaves the cached identity untouched. No-op when signed out.
|
||||
*/
|
||||
suspend fun refreshProfile() {
|
||||
if (!isSignedIn) return
|
||||
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
|
||||
val user = UserRef(
|
||||
id = p.id,
|
||||
username = p.username,
|
||||
isAdmin = p.isAdmin,
|
||||
debugModeEnabled = p.debugModeEnabled,
|
||||
)
|
||||
currentUserState.value = user
|
||||
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the local session immediately and best-effort hits
|
||||
* `/api/auth/logout` so the server can drop its session row.
|
||||
|
||||
@@ -59,6 +59,9 @@ class AuthStore @Inject constructor(
|
||||
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
|
||||
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
||||
|
||||
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
init {
|
||||
@@ -70,6 +73,7 @@ class AuthStore @Inject constructor(
|
||||
themeModeState.value = row?.themeMode
|
||||
clientIdState.value = row?.clientId
|
||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +116,11 @@ class AuthStore @Inject constructor(
|
||||
scope.launch { persistCacheSettings(encoded) }
|
||||
}
|
||||
|
||||
fun setDiagnosticsOptOut(value: Boolean) {
|
||||
diagnosticsOptOutState.value = value
|
||||
scope.launch { persistDiagnosticsOptOut(value) }
|
||||
}
|
||||
|
||||
private suspend fun persistCookie(value: String?) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||
@@ -160,6 +169,14 @@ class AuthStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistDiagnosticsOptOut(value: Boolean) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(diagnosticsOptOut = value))
|
||||
} else {
|
||||
dao.setDiagnosticsOptOut(value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||
id = ROW_ID,
|
||||
sessionCookie = sessionCookieState.value,
|
||||
@@ -171,6 +188,7 @@ class AuthStore @Inject constructor(
|
||||
CacheSettings.serializer(),
|
||||
cacheSettingsState.value,
|
||||
),
|
||||
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||
@@ -30,6 +31,7 @@ import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
|
||||
/**
|
||||
@@ -61,8 +63,11 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
CachedHomeIndexEntity::class,
|
||||
CachedHistorySnapshotEntity::class,
|
||||
AuthSessionEntity::class,
|
||||
DiagnosticEventEntity::class,
|
||||
],
|
||||
version = 6,
|
||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
|
||||
version = 7,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(MinstrelTypeConverters::class)
|
||||
@@ -81,4 +86,5 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
|
||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||
abstract fun authSessionDao(): AuthSessionDao
|
||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||
@@ -105,5 +106,10 @@ object DatabaseModule {
|
||||
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
|
||||
db.audioCacheIndexDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||
db.diagnosticEventDao()
|
||||
|
||||
private const val DATABASE_NAME = "minstrel.db"
|
||||
}
|
||||
|
||||
+4
@@ -42,4 +42,8 @@ interface AuthSessionDao {
|
||||
/** Partial update: change only the serialized cache settings. */
|
||||
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
|
||||
suspend fun setCacheSettingsJson(json: String?)
|
||||
|
||||
/** Partial update: change only the per-device diagnostics opt-out. */
|
||||
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
||||
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
||||
}
|
||||
|
||||
+24
-3
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -21,12 +22,32 @@ interface CachedHomeIndexDao {
|
||||
)
|
||||
suspend fun getBySection(section: String): List<CachedHomeIndexEntity>
|
||||
|
||||
/** True when Home has any cached section rows to render. */
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM cached_home_index)")
|
||||
suspend fun hasAny(): Boolean
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
|
||||
|
||||
/** Replace-all pattern; sync wipes a section then re-inserts. */
|
||||
@Query("DELETE FROM cached_home_index WHERE section = :section")
|
||||
suspend fun deleteBySection(section: String)
|
||||
@Query("DELETE FROM cached_home_index WHERE section IN (:sections)")
|
||||
suspend fun deleteSections(sections: List<String>)
|
||||
|
||||
/**
|
||||
* Swaps every listed section's rows in ONE transaction.
|
||||
*
|
||||
* Atomicity is the point, not just tidiness: Room's
|
||||
* InvalidationTracker only notifies observers after the transaction
|
||||
* commits, so [observeBySection] never sees the empty gap between the
|
||||
* delete and the re-insert. Replacing sections one at a time (and
|
||||
* un-transacted) made each Home row emit `emptyList()` — visibly
|
||||
* collapsing — before refilling, and made the seven sections do it in
|
||||
* sequence rather than as a single content swap.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replaceSections(sections: List<String>, rows: List<CachedHomeIndexEntity>) {
|
||||
deleteSections(sections)
|
||||
if (rows.isNotEmpty()) upsertAll(rows)
|
||||
}
|
||||
|
||||
@Query("DELETE FROM cached_home_index")
|
||||
suspend fun clear()
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.fabledsword.minstrel.cache.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||
|
||||
@Dao
|
||||
interface DiagnosticEventDao {
|
||||
@Insert
|
||||
suspend fun insert(row: DiagnosticEventEntity): Long
|
||||
|
||||
/** FIFO drain order so the uploader sends oldest-first. */
|
||||
@Query("SELECT * FROM diagnostic_events ORDER BY id ASC LIMIT :limit")
|
||||
suspend fun takeBatch(limit: Int): List<DiagnosticEventEntity>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM diagnostic_events")
|
||||
suspend fun count(): Int
|
||||
|
||||
@Query("DELETE FROM diagnostic_events WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<Long>)
|
||||
|
||||
/**
|
||||
* Ring-buffer trim: drop the oldest rows beyond [keep]. Called after
|
||||
* insert so a long offline stretch can't grow the buffer unbounded.
|
||||
*/
|
||||
@Query(
|
||||
"DELETE FROM diagnostic_events WHERE id NOT IN " +
|
||||
"(SELECT id FROM diagnostic_events ORDER BY id DESC LIMIT :keep)",
|
||||
)
|
||||
suspend fun trimToNewest(keep: Int)
|
||||
|
||||
@Query("DELETE FROM diagnostic_events")
|
||||
suspend fun clear()
|
||||
}
|
||||
+7
@@ -36,4 +36,11 @@ data class AuthSessionEntity(
|
||||
* CacheSettings shape evolves.
|
||||
*/
|
||||
val cacheSettingsJson: String? = null,
|
||||
/**
|
||||
* Per-device opt-out of diagnostics reporting (M9). When the
|
||||
* account's debug mode is enabled by an admin, the user can still
|
||||
* turn reporting OFF on this device (battery/privacy) — that local
|
||||
* choice lives here. Default false = honor the account flag.
|
||||
*/
|
||||
val diagnosticsOptOut: Boolean = false,
|
||||
)
|
||||
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
package com.fabledsword.minstrel.cache.db.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* One buffered device-diagnostics event (M9). The DiagnosticsReporter
|
||||
* writes rows here when the account's debug mode is on; the
|
||||
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes
|
||||
* on success.
|
||||
*
|
||||
* This is a deliberate ring buffer that is NOT routed through the offline
|
||||
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and
|
||||
* the bug we're chasing (roaming / dead-zone recovery) happens WHILE
|
||||
* offline — so events must persist locally through the dead zone and
|
||||
* upload on recovery, without clogging the user-data mutation replay.
|
||||
*
|
||||
* `occurredAtMillis` is the device-clock epoch-ms when the event happened
|
||||
* (the server also stamps its own received_at). `payloadJson` is an
|
||||
* opaque JSON object carrying the event sub-type + fields.
|
||||
*/
|
||||
@Entity(tableName = "diagnostic_events")
|
||||
data class DiagnosticEventEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val kind: String,
|
||||
val occurredAtMillis: Long,
|
||||
val payloadJson: String,
|
||||
)
|
||||
+3
@@ -239,6 +239,9 @@ data class PlayOfflinePayload(
|
||||
val atIso: String,
|
||||
val durationPlayedMs: Long,
|
||||
val source: String? = null,
|
||||
// #1551: device class for context conditioning; null on payloads queued
|
||||
// before this field existed (decodes to null → server stores NULL).
|
||||
val deviceClass: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -254,6 +254,7 @@ class MutationReplayer @Inject constructor(
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
deviceClass = decoded.deviceClass,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
|
||||
+18
-1
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel.cache.sync
|
||||
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
@@ -17,6 +18,9 @@ import com.fabledsword.minstrel.models.wire.SyncAlbumWire
|
||||
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
@@ -64,6 +68,17 @@ class SyncController @Inject constructor(
|
||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val lastSyncErrorInternal = MutableStateFlow<String?>(null)
|
||||
|
||||
/**
|
||||
* Human copy for the most recent sync failure; null after any clean
|
||||
* pass. Lets the Library screen distinguish "empty because the first
|
||||
* sync failed" (show error + retry) from "genuinely empty library"
|
||||
* (show welcome copy) — a failed sync over a populated cache stays
|
||||
* silent, since the cached content is still the better surface.
|
||||
*/
|
||||
val lastSyncError: StateFlow<String?> = lastSyncErrorInternal.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
authStore.sessionCookie
|
||||
@@ -82,11 +97,13 @@ class SyncController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
||||
/** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */
|
||||
suspend fun syncSafe() {
|
||||
if (!mutex.tryLock()) return
|
||||
try {
|
||||
runCatching { sync() }
|
||||
.onSuccess { lastSyncErrorInternal.value = null }
|
||||
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
|
||||
} finally {
|
||||
mutex.unlock()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Emits once each time server health RETURNS to [ServerHealth.Healthy]
|
||||
* after the collector subscribed — the StateFlow's replayed current value
|
||||
* is dropped, so only genuine down→up transitions fire (a screen that
|
||||
* subscribes while already Healthy doesn't double-load).
|
||||
*
|
||||
* This is the screen-level half of the recovery idiom the app-lifetime
|
||||
* singletons (SyncController / MutationReplayer / DiagnosticsUploader)
|
||||
* already use: a ViewModel collects this in its viewModelScope and re-runs
|
||||
* its load, so a surface that failed while the server was unreachable
|
||||
* heals itself the moment connectivity returns instead of sitting in the
|
||||
* failed state until a manual pull-to-refresh.
|
||||
*/
|
||||
fun NetworkStatusController.recoveries(): Flow<Unit> = state
|
||||
.map { it == ServerHealth.Healthy }
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.filter { it }
|
||||
.map { }
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
package com.fabledsword.minstrel.diagnostics
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.PowerManager
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.auth.AuthController
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.player.RemotePlayerState
|
||||
import com.fabledsword.minstrel.player.output.OutputPickerController
|
||||
import com.fabledsword.minstrel.player.output.OutputRoute
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* On-device diagnostics collector (M9). Active only while the account's
|
||||
* debug mode is enabled (server flag, read via AuthController.currentUser)
|
||||
* AND the user hasn't opted out locally. When active it taps existing
|
||||
* connectivity / player / UPnP / power seams READ-ONLY and writes a
|
||||
* timeseries of events into the [DiagnosticEventDao] ring buffer; the
|
||||
* [DiagnosticsUploader] drains that buffer to the server.
|
||||
*
|
||||
* The aim is to explain two failure classes after the fact: roaming /
|
||||
* poor-data recovery (connectivity + server-health transitions) and
|
||||
* locked-phone UPnP desync (power/Doze transitions correlated with the
|
||||
* Sonos-vs-local position deltas captured in the heartbeat).
|
||||
*/
|
||||
@Suppress("TooManyFunctions") // one collector + one (un)register pair per signal; cohesive
|
||||
@Singleton
|
||||
class DiagnosticsReporter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val dao: DiagnosticEventDao,
|
||||
private val authController: AuthController,
|
||||
private val authStore: AuthStore,
|
||||
private val connectivity: ConnectivityObserver,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
private val playerController: PlayerController,
|
||||
private val outputPicker: OutputPickerController,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val json: Json,
|
||||
) {
|
||||
@Volatile private var enabled = false
|
||||
private var collectorJob: Job? = null
|
||||
private var powerReceiver: BroadcastReceiver? = null
|
||||
private var lifecycleObserver: DefaultLifecycleObserver? = null
|
||||
|
||||
init {
|
||||
// Gate = account flag (currentUser.debugModeEnabled) AND not the
|
||||
// per-device local opt-out.
|
||||
scope.launch {
|
||||
combine(authController.currentUser, authStore.diagnosticsOptOut) { user, optOut ->
|
||||
(user?.debugModeEnabled == true) && !optOut
|
||||
}.distinctUntilChanged().collect { setEnabled(it) }
|
||||
}
|
||||
// Periodically re-pull /me so a remote admin flip propagates
|
||||
// without a re-login or app restart.
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(GATE_REFRESH_MS)
|
||||
authController.refreshProfile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setEnabled(value: Boolean) {
|
||||
if (value == enabled) return
|
||||
enabled = value
|
||||
if (value) start() else stop()
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
registerPowerReceiver()
|
||||
registerLifecycleObserver()
|
||||
collectorJob = scope.launch {
|
||||
record("lifecycle", buildJsonObject { put("event", "reporting_started") })
|
||||
launch { collectConnectivity() }
|
||||
launch { collectServerHealth() }
|
||||
launch { collectUpnpDrops() }
|
||||
launch { collectPlayerState() }
|
||||
launch { collectTrackChanges() }
|
||||
launch { collectRoutes() }
|
||||
launch { heartbeatLoop() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
collectorJob?.cancel()
|
||||
collectorJob = null
|
||||
unregisterPowerReceiver()
|
||||
unregisterLifecycleObserver()
|
||||
// Off means off: drop any unsent buffer (honors the user OFF
|
||||
// intent and avoids leaking data captured before disable).
|
||||
scope.launch { runCatching { dao.clear() } }
|
||||
}
|
||||
|
||||
private suspend fun collectConnectivity() {
|
||||
connectivity.online.distinctUntilChanged().collect { online ->
|
||||
record("connectivity", buildJsonObject {
|
||||
put("event", "online_changed")
|
||||
put("online", online)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun collectServerHealth() {
|
||||
// state is a StateFlow — already conflated/distinct, so no
|
||||
// distinctUntilChanged (it's a deprecation warning = build error).
|
||||
networkStatus.state.collect { s ->
|
||||
record("connectivity", buildJsonObject {
|
||||
put("event", "server_health")
|
||||
put("state", s.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun collectUpnpDrops() {
|
||||
playerController.dropEvents.collect { msg ->
|
||||
record("upnp_sync", buildJsonObject {
|
||||
put("event", "drop")
|
||||
put("message", msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun collectPlayerState() {
|
||||
// 'playback', not 'upnp_sync' — player state applies to every output
|
||||
// route (phone speaker, Bluetooth, UPnP), not just casting.
|
||||
playerController.uiState
|
||||
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
|
||||
.distinctUntilChanged()
|
||||
.collect { (source, upnpLoading, err) ->
|
||||
record("playback", buildJsonObject {
|
||||
put("event", "player_state")
|
||||
put("source", source ?: "")
|
||||
put("upnp_loading", upnpLoading)
|
||||
if (err != null) put("error", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Emit a snapshot at each track/queue-index change — captures a
|
||||
// skip-induced local↔Sonos desync at the INSTANT it happens, which the
|
||||
// 45s heartbeat misses. Note: uiState is a conflated StateFlow, so a
|
||||
// very rapid skip burst may coalesce intermediate indices (we still get
|
||||
// the boundaries + the local-vs-Sonos snapshot).
|
||||
private suspend fun collectTrackChanges() {
|
||||
playerController.uiState
|
||||
.map { it.queueIndex to it.currentTrack?.id }
|
||||
.distinctUntilChanged()
|
||||
.collect { (index, _) ->
|
||||
val ui = playerController.uiState.value
|
||||
val casting = outputPicker.routesState.value.current.protocol !=
|
||||
OutputRoute.Protocol.SYSTEM
|
||||
record("playback", buildJsonObject {
|
||||
put("event", "track_change")
|
||||
put("local_index", index)
|
||||
// Track IDENTITY, not just index — indices wobble across
|
||||
// re-casts, so the id + Sonos URI make a desync unambiguous.
|
||||
put("local_track_id", ui.currentTrack?.id ?: "")
|
||||
put("local_pos_ms", ui.positionMs)
|
||||
putSonos(this, casting)
|
||||
put("upnp_loading", ui.isUpnpLoading)
|
||||
put("server_health", networkStatus.state.value.name)
|
||||
put("route", outputPicker.routesState.value.current.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun collectRoutes() {
|
||||
// 'playback' — route changes happen for all outputs. This only ever
|
||||
// logs the ACTIVE route (routesState.current), so no "connected" flag.
|
||||
outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
|
||||
record("playback", buildJsonObject {
|
||||
put("event", "route")
|
||||
put("id", r.id)
|
||||
put("name", r.name)
|
||||
put("kind", r.kind.name)
|
||||
put("protocol", r.protocol.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun heartbeatLoop() {
|
||||
while (true) {
|
||||
delay(HEARTBEAT_MS)
|
||||
val ui = playerController.uiState.value
|
||||
val route = outputPicker.routesState.value.current
|
||||
val routeActive = route.protocol != OutputRoute.Protocol.SYSTEM
|
||||
// Idle = nothing worth sampling. Skip to keep the buffer lean.
|
||||
if (!ui.isPlaying && !routeActive) continue
|
||||
record("heartbeat", buildJsonObject {
|
||||
put("server_health", networkStatus.state.value.name)
|
||||
put("is_playing", ui.isPlaying)
|
||||
put("source", ui.currentSource ?: "")
|
||||
put("local_index", ui.queueIndex)
|
||||
put("local_track_id", ui.currentTrack?.id ?: "")
|
||||
put("local_pos_ms", ui.positionMs)
|
||||
put("route", route.name)
|
||||
put("route_protocol", route.protocol.name)
|
||||
putSonos(this, routeActive)
|
||||
addPowerFields(this)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- power + lifecycle taps -------------------------------------------
|
||||
|
||||
private fun registerPowerReceiver() {
|
||||
if (powerReceiver != null) return
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
val action = intent?.action ?: return
|
||||
if (!enabled) return
|
||||
scope.launch { record("power", powerEvent(action)) }
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
|
||||
}
|
||||
// System-protected broadcasts, but pass NOT_EXPORTED explicitly so
|
||||
// the API 34+ registerReceiver flag requirement is satisfied.
|
||||
ContextCompat.registerReceiver(
|
||||
context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
powerReceiver = receiver
|
||||
}
|
||||
|
||||
private fun unregisterPowerReceiver() {
|
||||
powerReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
powerReceiver = null
|
||||
}
|
||||
|
||||
private fun registerLifecycleObserver() {
|
||||
if (lifecycleObserver != null) return
|
||||
val obs = object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) = logLifecycle("app_foreground")
|
||||
override fun onStop(owner: LifecycleOwner) = logLifecycle("app_background")
|
||||
}
|
||||
lifecycleObserver = obs
|
||||
scope.launch(Dispatchers.Main) {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(obs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterLifecycleObserver() {
|
||||
val obs = lifecycleObserver ?: return
|
||||
lifecycleObserver = null
|
||||
scope.launch(Dispatchers.Main) {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(obs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun logLifecycle(event: String) {
|
||||
if (!enabled) return
|
||||
scope.launch { record("lifecycle", buildJsonObject { put("event", event) }) }
|
||||
}
|
||||
|
||||
private fun powerEvent(action: String): JsonObject = buildJsonObject {
|
||||
put("event", action.substringAfterLast('.'))
|
||||
addPowerFields(this)
|
||||
}
|
||||
|
||||
// Sonos/UPnP remote-vs-local desync fields. Only meaningful while a
|
||||
// remote route is active; zeroed otherwise so a stale RemotePlayerState
|
||||
// from a just-ended cast can't masquerade as live Sonos data (the
|
||||
// cast→phone handoff artifact). currentTrackUri is the desync ground
|
||||
// truth — it carries the track the speaker is actually streaming.
|
||||
private fun putSonos(builder: kotlinx.serialization.json.JsonObjectBuilder, casting: Boolean) {
|
||||
builder.put("sonos_track", if (casting) remoteState.trackNumber else 0)
|
||||
builder.put("sonos_pos_ms", if (casting) remoteState.positionMs else 0)
|
||||
builder.put("sonos_playing", casting && remoteState.isPlaying)
|
||||
if (casting) builder.put("sonos_uri", remoteState.currentTrackUri)
|
||||
}
|
||||
|
||||
private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
builder.put("doze", pm.isDeviceIdleMode)
|
||||
builder.put("screen_on", pm.isInteractive)
|
||||
builder.put("battery_opt_ignored", pm.isIgnoringBatteryOptimizations(context.packageName))
|
||||
}
|
||||
|
||||
private suspend fun record(kind: String, payload: JsonObject) {
|
||||
runCatching {
|
||||
val id = dao.insert(
|
||||
DiagnosticEventEntity(
|
||||
kind = kind,
|
||||
occurredAtMillis = System.currentTimeMillis(),
|
||||
payloadJson = json.encodeToString(JsonObject.serializer(), payload),
|
||||
),
|
||||
)
|
||||
if (id % TRIM_INTERVAL == 0L) dao.trimToNewest(MAX_BUFFER)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HEARTBEAT_MS = 45_000L
|
||||
const val GATE_REFRESH_MS = 300_000L // re-pull /me every 5 min
|
||||
const val MAX_BUFFER = 5_000 // ring-buffer ceiling, oldest dropped
|
||||
const val TRIM_INTERVAL = 50L // trim every N inserts, not every one
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.fabledsword.minstrel.diagnostics
|
||||
|
||||
import android.os.Build
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.api.endpoints.DiagnosticEventWire
|
||||
import com.fabledsword.minstrel.api.endpoints.DiagnosticsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.DiagnosticsReportRequest
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics
|
||||
* (M9). Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
|
||||
* a periodic tick, a sign-in (sessionCookie non-null), and a server-health
|
||||
* recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events
|
||||
* upload the moment the link comes back.
|
||||
*
|
||||
* Deliberately NOT the MutationQueue: diagnostics are high-volume,
|
||||
* best-effort telemetry. A failed upload just retries on the next tick;
|
||||
* the server no-ops (204) when the account's debug flag is off, which the
|
||||
* uploader treats as success so the buffer drains either way.
|
||||
*/
|
||||
@Singleton
|
||||
class DiagnosticsUploader @Inject constructor(
|
||||
private val dao: DiagnosticEventDao,
|
||||
private val authStore: AuthStore,
|
||||
private val json: Json,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
networkStatus: NetworkStatusController,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: DiagnosticsApi = retrofit.create()
|
||||
private val mutex = Mutex()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(UPLOAD_INTERVAL_MS)
|
||||
drainSafe()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
authStore.sessionCookie.filterNotNull().distinctUntilChanged().collect { drainSafe() }
|
||||
}
|
||||
scope.launch {
|
||||
networkStatus.state
|
||||
.map { it == ServerHealth.Healthy }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { drainSafe() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Coalesces concurrent triggers via tryLock — a drain in flight wins. */
|
||||
private suspend fun drainSafe() {
|
||||
val clientId = authStore.clientId.value
|
||||
// Signed out / no client id yet → nothing to do. Single guard keeps
|
||||
// the return count within the detekt gate.
|
||||
if (authStore.sessionCookie.value.isNullOrEmpty() || clientId == null) return
|
||||
if (!mutex.tryLock()) return
|
||||
try {
|
||||
drain(clientId)
|
||||
} finally {
|
||||
mutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun drain(clientId: String) {
|
||||
var more = true
|
||||
while (more) {
|
||||
val batch = runCatching { dao.takeBatch(BATCH_SIZE) }.getOrNull().orEmpty()
|
||||
val sent = batch.isNotEmpty() && runCatching { upload(clientId, batch) }.isSuccess
|
||||
if (sent) runCatching { dao.deleteByIds(batch.map { it.id }) }
|
||||
// Keep going only while a full batch sent cleanly; otherwise stop
|
||||
// (empty buffer, or a failure to retry on the next trigger).
|
||||
more = sent && batch.size >= BATCH_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun upload(clientId: String, batch: List<DiagnosticEventEntity>) {
|
||||
api.report(
|
||||
DiagnosticsReportRequest(
|
||||
clientId = clientId,
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
osVersion = "Android ${Build.VERSION.RELEASE} (${Build.MODEL})",
|
||||
events = batch.map { row ->
|
||||
DiagnosticEventWire(
|
||||
kind = row.kind,
|
||||
occurredAt = row.occurredAtMillis,
|
||||
payload = json.parseToJsonElement(row.payloadJson),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UPLOAD_INTERVAL_MS = 60_000L
|
||||
const val BATCH_SIZE = 100
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.nav.Discover
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
@@ -109,6 +110,7 @@ private fun DiscoverBody(
|
||||
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
||||
}
|
||||
},
|
||||
onRetry = { viewModel.loadSuggestions() },
|
||||
)
|
||||
ResultsState.Loading -> LoadingCentered()
|
||||
is ResultsState.Loaded -> ResultsList(
|
||||
@@ -121,7 +123,11 @@ private fun DiscoverBody(
|
||||
}
|
||||
},
|
||||
)
|
||||
is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}")
|
||||
is ResultsState.Error -> ErrorRetry(
|
||||
title = "Search failed",
|
||||
message = r.message,
|
||||
onRetry = viewModel::runSearch,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,10 +183,15 @@ private fun SuggestionsPane(
|
||||
state: SuggestionState,
|
||||
locallyRequestedMbids: Set<String>,
|
||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
SuggestionState.Loading -> LoadingCentered()
|
||||
is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.")
|
||||
is SuggestionState.Error -> ErrorRetry(
|
||||
title = "Couldn't load suggestions",
|
||||
message = state.message,
|
||||
onRetry = onRetry,
|
||||
)
|
||||
is SuggestionState.Loaded -> SuggestionsList(
|
||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||
onRequest = onRequest,
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.discover.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
@@ -46,6 +48,7 @@ sealed interface ResultsState {
|
||||
@HiltViewModel
|
||||
class DiscoverViewModel @Inject constructor(
|
||||
private val repository: DiscoverRepository,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow(DiscoverState())
|
||||
@@ -53,6 +56,15 @@ class DiscoverViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
loadSuggestions()
|
||||
// Screen-level auto-recovery (issue #1245): re-run whichever pane
|
||||
// failed while the server was unreachable once health returns.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
val s = internal.value
|
||||
if (s.suggestions is SuggestionState.Error) loadSuggestions()
|
||||
if (s.results is ResultsState.Error) runSearch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setQuery(value: String) {
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.history.data.HistoryEntry
|
||||
import com.fabledsword.minstrel.history.data.HistoryRepository
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
@@ -26,6 +28,7 @@ import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||
@@ -56,6 +59,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
class HistoryTabViewModel @Inject constructor(
|
||||
private val repository: HistoryRepository,
|
||||
private val player: PlayerController,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
@@ -82,6 +86,11 @@ class HistoryTabViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||
// health returns, so a load that failed offline heals unprompted.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
@@ -116,9 +125,10 @@ fun HistoryTab(
|
||||
title = "No listening history yet",
|
||||
body = "Play something — your recent plays will show up here.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load history",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is UiState.Success -> HistoryList(
|
||||
entries = s.data,
|
||||
|
||||
@@ -14,8 +14,10 @@ import com.fabledsword.minstrel.models.TrackRef
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -34,9 +36,10 @@ import javax.inject.Singleton
|
||||
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
||||
* per-item tile providers.
|
||||
*
|
||||
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section
|
||||
* in-place (delete-then-insert, so the section Flows re-fire), and
|
||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
||||
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
|
||||
* one transaction (so the rows update together in a single emission
|
||||
* rather than collapsing and refilling), and pre-warms the top artists
|
||||
* via [HomeArtistPrewarmer].
|
||||
*/
|
||||
@Singleton
|
||||
// Per-section observe accessors (one per Home row) inflate the function
|
||||
@@ -85,72 +88,98 @@ class HomeRepository @Inject constructor(
|
||||
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
||||
|
||||
/** True when the index cache already has content on screen to protect. */
|
||||
suspend fun hasCachedIndex(): Boolean = homeIndexDao.hasAny()
|
||||
|
||||
/**
|
||||
* Pulls /api/home/index, replaces each cached_home_index section,
|
||||
* and pre-warms the top artists. The section Flows re-fire on the
|
||||
* index change; missing entity rows hydrate via the on-miss path.
|
||||
* Pulls /api/home/index and swaps every cached_home_index section in
|
||||
* a single transaction, then pre-warms the top artists. Missing
|
||||
* entity rows hydrate via the on-miss path.
|
||||
*
|
||||
* One transaction for all seven sections is deliberate: Room notifies
|
||||
* observers once, on commit, so Home swaps from the old content to
|
||||
* the new in a single emission. Per-section, un-transacted writes
|
||||
* made every row visibly collapse to empty and refill, one after
|
||||
* another (issue #2327).
|
||||
*/
|
||||
suspend fun refreshIndex() {
|
||||
val wire = api.getHomeIndex()
|
||||
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums)
|
||||
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums)
|
||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
|
||||
homeIndexDao.replaceSections(
|
||||
sections = ALL_SECTIONS,
|
||||
rows = rowsFor(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) +
|
||||
rowsFor(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) +
|
||||
rowsFor(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) +
|
||||
rowsFor(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) +
|
||||
rowsFor(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) +
|
||||
rowsFor(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums) +
|
||||
rowsFor(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists),
|
||||
)
|
||||
prewarmer.warm(
|
||||
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
||||
homeIndexDao.deleteBySection(section)
|
||||
if (ids.isEmpty()) return
|
||||
homeIndexDao.upsertAll(
|
||||
ids.mapIndexed { index, id ->
|
||||
private fun rowsFor(
|
||||
section: String,
|
||||
entityType: String,
|
||||
ids: List<String>,
|
||||
): List<CachedHomeIndexEntity> = ids.mapIndexed { index, id ->
|
||||
CachedHomeIndexEntity(
|
||||
section = section,
|
||||
position = index,
|
||||
entityType = entityType,
|
||||
entityId = id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The section's ordered entity ids, deduplicated.
|
||||
*
|
||||
* Room re-runs the query on every write to `cached_home_index` — and
|
||||
* `CachedHomeIndexEntity.fetchedAt` is stamped fresh each time — so
|
||||
* comparing whole rows would call every rewrite a change. Comparing
|
||||
* the id list instead means a section whose contents didn't actually
|
||||
* move never restarts the `flatMapLatest` below, which would
|
||||
* otherwise tear down and rebuild all of its tiles' hydration flows
|
||||
* and flicker unchanged tiles (issue #2327).
|
||||
*/
|
||||
private fun observeSectionIds(section: String): Flow<List<String>> =
|
||||
homeIndexDao.observeBySection(section)
|
||||
.map { rows -> rows.map { it.entityId } }
|
||||
.distinctUntilChanged()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
||||
if (rows.isEmpty()) {
|
||||
observeSectionIds(section).flatMapLatest { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
|
||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
||||
combine(ids.map { metadataProvider.observeAlbum(it) }) { refs ->
|
||||
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
||||
if (rows.isEmpty()) {
|
||||
observeSectionIds(section).flatMapLatest { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
|
||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
||||
combine(ids.map { metadataProvider.observeArtist(it) }) { refs ->
|
||||
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
||||
if (rows.isEmpty()) {
|
||||
observeSectionIds(section).flatMapLatest { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
|
||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
||||
combine(ids.map { metadataProvider.observeTrack(it) }) { refs ->
|
||||
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,5 +192,16 @@ class HomeRepository @Inject constructor(
|
||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
||||
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
||||
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
||||
|
||||
/** Every section [refreshIndex] owns — the unit of one atomic swap. */
|
||||
val ALL_SECTIONS = listOf(
|
||||
SECTION_RECENTLY_ADDED_ALBUMS,
|
||||
SECTION_REDISCOVER_ALBUMS,
|
||||
SECTION_REDISCOVER_ARTISTS,
|
||||
SECTION_MOST_PLAYED_TRACKS,
|
||||
SECTION_LAST_PLAYED_ARTISTS,
|
||||
SECTION_YOU_MIGHT_LIKE_ALBUMS,
|
||||
SECTION_YOU_MIGHT_LIKE_ARTISTS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
package com.fabledsword.minstrel.home.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -27,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -34,6 +43,7 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -55,6 +65,7 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
@@ -75,27 +86,38 @@ import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.UpdateVeilController
|
||||
import com.fabledsword.minstrel.shared.VeilOutcome
|
||||
import com.fabledsword.minstrel.shared.VeilSessionResult
|
||||
import com.fabledsword.minstrel.shared.VeilSettleState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.LocalArtSettleTracker
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
@@ -107,6 +129,22 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
||||
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
||||
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
||||
|
||||
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
|
||||
// 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,
|
||||
// which lowered it while tiles and artwork were still landing (#2327).
|
||||
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
||||
private const val VEIL_WIPE_MS = 280
|
||||
private const val VEIL_ALPHA = 0.96f
|
||||
private const val VEIL_SPINNER_DP = 22
|
||||
private const val VEIL_SPINNER_STROKE_DP = 2
|
||||
private const val VEIL_LABEL_GAP_DP = 12
|
||||
|
||||
// Backstop on how long a manual pull keeps its own indicator while waiting
|
||||
// for its successor — the veil, or the "already up to date" snackbar — so the
|
||||
// two never both vanish for a frame mid-handoff.
|
||||
private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
data class HomeSections(
|
||||
@@ -139,6 +177,7 @@ class HomeViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
|
||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -160,14 +199,29 @@ class HomeViewModel @Inject constructor(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
||||
private val snackbarMessages = Channel<String>(Channel.BUFFERED)
|
||||
|
||||
/** Transient snackbar messages from offline-pool taps. */
|
||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||
/**
|
||||
* Transient snackbar messages: offline-pool taps, playback failures, and
|
||||
* the outcome of a refresh the user explicitly asked for.
|
||||
*/
|
||||
val transientMessages: Flow<String> = snackbarMessages.receiveAsFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
/**
|
||||
* Copy for the most recent /home/index refresh failure; null once a
|
||||
* refresh succeeds. Folded into [uiState] so an empty cache + failed
|
||||
* refresh renders as Error (with retry) instead of masquerading as
|
||||
* the "Welcome to Minstrel" empty state.
|
||||
*/
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
|
||||
/**
|
||||
* Cover-art loads in flight on Home, reported by every [ServerImage]
|
||||
* under [LocalArtSettleTracker]. The veil waits on this so artwork
|
||||
* arriving a beat after its tile lands behind the veil rather than
|
||||
* popping in on screen.
|
||||
*/
|
||||
val artTracker = ArtSettleTracker()
|
||||
|
||||
/**
|
||||
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
||||
@@ -180,7 +234,7 @@ class HomeViewModel @Inject constructor(
|
||||
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
||||
}.shuffled()
|
||||
if (tracks.isEmpty()) {
|
||||
poolMessages.trySend("No cached ${kind.label} tracks yet")
|
||||
snackbarMessages.trySend("No cached ${kind.label} tracks yet")
|
||||
} else {
|
||||
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
||||
}
|
||||
@@ -217,14 +271,14 @@ class HomeViewModel @Inject constructor(
|
||||
try {
|
||||
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
||||
if (detail.tracks.isEmpty()) {
|
||||
poolMessages.trySend("This album has no tracks to play.")
|
||||
snackbarMessages.trySend("This album has no tracks to play.")
|
||||
} else {
|
||||
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
poolMessages.trySend(
|
||||
snackbarMessages.trySend(
|
||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
@@ -242,14 +296,14 @@ class HomeViewModel @Inject constructor(
|
||||
try {
|
||||
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
||||
if (tracks.isEmpty()) {
|
||||
poolMessages.trySend("This artist has no tracks to play.")
|
||||
snackbarMessages.trySend("This artist has no tracks to play.")
|
||||
} else {
|
||||
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
poolMessages.trySend(
|
||||
snackbarMessages.trySend(
|
||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
@@ -267,26 +321,67 @@ class HomeViewModel @Inject constructor(
|
||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||
viewModelScope.launch {
|
||||
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
||||
poolMessages.trySend(it)
|
||||
snackbarMessages.trySend(it)
|
||||
}
|
||||
}.join()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls both /home/index and the playlists list. Returns the Job
|
||||
* for the combined refresh so a pull-to-refresh wrapper can await
|
||||
* actual completion before hiding the indicator.
|
||||
* Pulls /home/index, the playlists list and the system-playlist
|
||||
* status. Returns true when the load-bearing /home/index pull
|
||||
* succeeded — the veil controller retries on false and reports the
|
||||
* outcome, so this must report failure rather than swallow it.
|
||||
*/
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
val home = launch { runCatching { homeRepository.refreshIndex() } }
|
||||
private suspend fun runRefresh(): Boolean = coroutineScope {
|
||||
// /home/index is the load-bearing pull: its failure drives the
|
||||
// empty-cache Error state. A failure over a populated cache
|
||||
// stays silent — cached sections beat a full-screen error.
|
||||
//
|
||||
// Cleared on success, NOT at the start of each attempt: with the
|
||||
// veil's retries, clearing up front made a failing cold start
|
||||
// flash the "Welcome to Minstrel" empty state (empty cache + no
|
||||
// error reads as Empty) between one attempt and the next.
|
||||
val home = async {
|
||||
runCatching { homeRepository.refreshIndex() }
|
||||
.onSuccess { refreshError.value = null }
|
||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||
.isSuccess
|
||||
}
|
||||
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||
val status = launch {
|
||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||
.onSuccess { systemStatusInternal.value = it }
|
||||
}
|
||||
home.join()
|
||||
lists.join()
|
||||
status.join()
|
||||
home.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Error state's explicit Retry button. User-initiated, so it gets
|
||||
* the controller's retries and reports its outcome; over an empty cache
|
||||
* there's no content to protect, so no veil goes up.
|
||||
*/
|
||||
fun retry() = veil.request(userInitiated = true)
|
||||
|
||||
/**
|
||||
* Manual pull-to-refresh. Goes behind the veil like every other refresh
|
||||
* (operator call, 2026-07-31: the churn a pull causes is identical to
|
||||
* the automatic paths, and a small spinner didn't hide it).
|
||||
*
|
||||
* 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
|
||||
* content changed, the "Already up to date" snackbar if it didn't. The
|
||||
* timeout is only a backstop against a session that outlives it.
|
||||
*/
|
||||
suspend fun refreshFromPull() {
|
||||
val before = veil.finishedSessions.value
|
||||
veil.request(userInitiated = true)
|
||||
withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) {
|
||||
combine(veil.visible, veil.finishedSessions) { veiled, finished ->
|
||||
veiled || finished != before
|
||||
}.first { it }
|
||||
}
|
||||
}
|
||||
|
||||
val uiState: StateFlow<UiState<HomeSections>> =
|
||||
@@ -325,9 +420,135 @@ class HomeViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun combineHomeFlows() =
|
||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
||||
combine(
|
||||
observeHomeSections(),
|
||||
playlistsRepository.observeAll(),
|
||||
refreshError,
|
||||
) { sections, playlists, err ->
|
||||
val merged = sections.copy(playlists = playlists)
|
||||
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
|
||||
when {
|
||||
!merged.isAllEmpty -> UiState.Success(merged)
|
||||
err != null -> UiState.Error(err)
|
||||
else -> UiState.Empty
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Updating veil ───────────────────────────────────────────────
|
||||
// Declared after uiState: these initialisers read it, and Kotlin runs
|
||||
// property initialisers and init blocks in declaration order.
|
||||
|
||||
/**
|
||||
* What the veil watches to decide Home has stopped moving: the whole
|
||||
* rendered state, plus how many covers are still loading.
|
||||
*
|
||||
* [UiState.Success] wraps a [HomeSections] data class, so any visible
|
||||
* change — a section swapping ids, one tile hydrating from skeleton to
|
||||
* album — changes this value and re-arms the veil's quiet window.
|
||||
*
|
||||
* Unhydrated tiles deliberately do NOT gate `quiescent`. A tile whose
|
||||
* on-miss fetch soft-fails keeps a null value indefinitely
|
||||
* ([MetadataProvider] swallows those errors), so treating "no
|
||||
* skeletons left" as the settle condition would pin the veil to its
|
||||
* hard ceiling on every refresh. They're covered by the content key
|
||||
* instead: each tile that lands re-arms the window, and once they stop
|
||||
* landing the screen is genuinely still.
|
||||
*/
|
||||
private val settleSignal: Flow<VeilSettleState> =
|
||||
combine(uiState, artTracker.inFlight) { state, artInFlight ->
|
||||
VeilSettleState(
|
||||
contentKey = state,
|
||||
hasContent = state is UiState.Success,
|
||||
quiescent = artInFlight == 0,
|
||||
)
|
||||
}
|
||||
|
||||
private val veil = UpdateVeilController(
|
||||
scope = viewModelScope,
|
||||
settleSignal = settleSignal,
|
||||
shouldVeil = {
|
||||
// Only worth hiding churn when there's already content to
|
||||
// hide. A cold load over an empty cache keeps its skeleton —
|
||||
// veiling that would replace a useful affordance with an
|
||||
// opaque panel. `hasCachedIndex` is the honest check: uiState
|
||||
// still reads Loading until the screen subscribes, so on a
|
||||
// process restore over a warm cache it would say "no content"
|
||||
// right before the cache emits.
|
||||
uiState.value is UiState.Success || homeRepository.hasCachedIndex()
|
||||
},
|
||||
onSessionEnd = ::reportRefreshOutcome,
|
||||
work = ::runRefresh,
|
||||
)
|
||||
|
||||
/**
|
||||
* Tells the user how a refresh *they asked for* went, in the one case
|
||||
* the veil can't: when nothing changed there's no veil to see, and a
|
||||
* pull that produces no visible response at all reads as broken.
|
||||
*
|
||||
* Only user-initiated sessions say anything. The same outcome from a
|
||||
* background check — the initial load, the 03:00 rebuild, a reconnect —
|
||||
* is noise, and "Already up to date" on every launch would be worse
|
||||
* than silence (operator's call, 2026-07-31).
|
||||
*/
|
||||
private fun reportRefreshOutcome(result: VeilSessionResult) {
|
||||
if (!result.userInitiated) return
|
||||
when (result.outcome) {
|
||||
// The veil was the feedback.
|
||||
VeilOutcome.CHANGED -> return
|
||||
VeilOutcome.UNCHANGED -> snackbarMessages.trySend("Already up to date")
|
||||
VeilOutcome.FAILED -> snackbarMessages.trySend("Couldn't check for updates")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the "Updating your mixes…" veil should be raised.
|
||||
*
|
||||
* Raised only when a refresh actually changes what's on screen, and then
|
||||
* held until Home settles — tiles hydrated, artwork loaded — instead of
|
||||
* for a fixed delay after the network pull returns (issue #2327). A
|
||||
* refresh that returns what's already cached shows no veil at all;
|
||||
* [reportRefreshOutcome] tells the user instead, if they asked.
|
||||
*/
|
||||
val isUpdating: StateFlow<Boolean> = veil.visible
|
||||
|
||||
init {
|
||||
// Every refresh path goes through the controller, which decides
|
||||
// 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
|
||||
// section, and it used to run completely unveiled.
|
||||
veil.request()
|
||||
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
||||
// load while the server was unreachable re-pulls itself the moment
|
||||
// health returns — same idiom as SyncController, one layer up.
|
||||
// This is also the recovery that keeps trying after the veil has
|
||||
// given up and lowered; the controller sets no latch against it.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { veil.request() }
|
||||
}
|
||||
// Server-side changes that rewrite what Home renders (#968 and
|
||||
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the
|
||||
// web SSE consumer.
|
||||
viewModelScope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind in VEILED_EVENT_KINDS }
|
||||
.collect { veil.request() }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Events that change what Home shows. `playlist.system_rebuilt`
|
||||
* is the 03:00 daily rebuild; the other `playlist.*` kinds move
|
||||
* the Playlists and Songs-like rows; `scan.run_finished` changes
|
||||
* Recently added (and Home never reacted to it at all before).
|
||||
*/
|
||||
private val VEILED_EVENT_KINDS = setOf(
|
||||
"playlist.system_rebuilt",
|
||||
"playlist.created",
|
||||
"playlist.updated",
|
||||
"playlist.deleted",
|
||||
"playlist.tracks_changed",
|
||||
"scan.run_finished",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,19 +577,47 @@ fun HomeScreen(
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
onRefresh = { viewModel.refreshFromPull() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
// Key Crossfade on the state CLASS, not the instance. Each
|
||||
// section emission produces a new UiState.Success(data); if
|
||||
// we keyed on `state` directly, every per-section
|
||||
// hydration tick would re-run the 300ms crossfade, and
|
||||
// first-sign-in (six sections cascading in) reads as
|
||||
// continuous flicker. Keying on the class restricts the
|
||||
// animation to Loading↔Success↔Empty↔Error transitions and
|
||||
// lets normal Success→Success recompositions update the
|
||||
// LazyColumn without a fade.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// Every cover below reports its load state to the tracker,
|
||||
// so the veil can wait for artwork instead of guessing.
|
||||
CompositionLocalProvider(
|
||||
LocalArtSettleTracker provides viewModel.artTracker,
|
||||
) {
|
||||
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||
}
|
||||
// Refresh veil: rebuild / reconnect / pull / event churn all
|
||||
// hide behind an "Updating your mixes…" wipe that stays up
|
||||
// until the screen has actually stopped moving.
|
||||
UpdatingVeil(visible = updating)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
|
||||
*
|
||||
* Key the Crossfade on the state CLASS, not the instance. Each section
|
||||
* emission produces a new UiState.Success(data); if we keyed on `state`
|
||||
* directly, every per-section hydration tick would re-run the 300ms
|
||||
* crossfade, and first-sign-in (six sections cascading in) reads as
|
||||
* continuous flicker. Keying on the class restricts the animation to
|
||||
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
|
||||
* recompositions update the LazyColumn without a fade.
|
||||
*/
|
||||
@Composable
|
||||
private fun HomeStateCrossfade(
|
||||
state: UiState<HomeSections>,
|
||||
systemStatus: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
navController: NavHostController,
|
||||
viewModel: HomeViewModel,
|
||||
) {
|
||||
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
||||
when (val s = state) {
|
||||
UiState.Loading -> HomeSkeletonContent()
|
||||
@@ -378,9 +627,10 @@ fun HomeScreen(
|
||||
"settings, then come back here for system playlists " +
|
||||
"and recommendations.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load home",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.retry() },
|
||||
)
|
||||
is UiState.Success -> HomeSuccessContent(
|
||||
sections = s.data,
|
||||
@@ -397,6 +647,45 @@ fun HomeScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-bleed "Updating your mixes…" veil that wipes in from the left,
|
||||
* holds while an automatic refresh repopulates Home, then wipes off.
|
||||
* Near-opaque so the section churn underneath never shows; swallows taps
|
||||
* while raised so a mid-hydration tile can't be hit.
|
||||
*/
|
||||
@Composable
|
||||
private fun BoxScope.UpdatingVeil(visible: Boolean) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
|
||||
exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
|
||||
modifier = Modifier.matchParentSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(VEIL_SPINNER_DP.dp),
|
||||
strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
|
||||
Text(
|
||||
text = "Updating your mixes…",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -465,10 +754,12 @@ private fun HomeSuccessContent(
|
||||
) {
|
||||
item {
|
||||
// Always rendered: real system/user playlists, with
|
||||
// placeholder cards filling the For You / Discover /
|
||||
// 3× Songs-like slots that haven't generated yet. When
|
||||
// offline, the cache-backed pool cards lead the row.
|
||||
// placeholder cards filling the For You / Discover slots
|
||||
// that haven't generated yet. When offline, the cache-backed
|
||||
// pool cards lead the row. Songs-like now lives in its own
|
||||
// dedicated row below (#1491), no longer inside this carousel.
|
||||
PlaylistsRow(
|
||||
title = "Playlists",
|
||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
@@ -476,6 +767,18 @@ private fun HomeSuccessContent(
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
// Songs-like is the best-performing surface (#1491) — promoted out
|
||||
// of the Playlists carousel into its own row so it shows a wider
|
||||
// spread of "Songs like {artist}" mixes. Hidden when there's
|
||||
// nothing to show (offline with none cached).
|
||||
songsLikeSection(
|
||||
playlists = sections.playlists,
|
||||
status = systemStatus,
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onPlayPool = onPlayPool,
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
youMightLikeSection(
|
||||
albums = sections.youMightLikeAlbums,
|
||||
artists = sections.youMightLikeArtists,
|
||||
@@ -499,6 +802,35 @@ private fun HomeSuccessContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
|
||||
* rendering with a distinct title; hidden entirely when there's nothing
|
||||
* to show (offline with no cached mixes). Online with none generated yet
|
||||
* still shows a few placeholders so the building / seed-needed state is
|
||||
* visible, matching the pre-promotion carousel behavior.
|
||||
*/
|
||||
private fun LazyListScope.songsLikeSection(
|
||||
playlists: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||
) {
|
||||
val rowItems = buildSongsLikeRow(playlists, status, offline)
|
||||
if (rowItems.isEmpty()) return
|
||||
item {
|
||||
PlaylistsRow(
|
||||
title = "Songs like…",
|
||||
rowItems = rowItems,
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onPlayPool = onPlayPool,
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.recentlyAddedSection(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
@@ -768,13 +1100,14 @@ private fun AlbumsRow(
|
||||
|
||||
@Composable
|
||||
private fun PlaylistsRow(
|
||||
title: String,
|
||||
rowItems: List<PlaylistRowItem>,
|
||||
offline: Boolean,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = "Playlists") {
|
||||
HorizontalScrollRow(title = title) {
|
||||
itemsIndexed(items = rowItems) { _, item ->
|
||||
when (item) {
|
||||
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
||||
@@ -825,11 +1158,12 @@ enum class OfflinePoolKind(val label: String) {
|
||||
/**
|
||||
* Builds the Home Playlists row.
|
||||
*
|
||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
||||
* Online: For You + Discover fixed slots (real card when generated,
|
||||
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||
* rediscover / new for you / on this day / first listens) when they exist —
|
||||
* no placeholders for these since they're conditional on library shape — then
|
||||
* user-owned playlists.
|
||||
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
|
||||
* [buildSongsLikeRow] and no longer appears in this carousel.
|
||||
*
|
||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||
* same real playlists in curated order but stably partitioned fully-cached
|
||||
@@ -856,7 +1190,7 @@ internal fun buildPlaylistsRow(
|
||||
return out
|
||||
}
|
||||
|
||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
||||
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
||||
private fun buildOnlineRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
@@ -868,12 +1202,7 @@ private fun buildOnlineRow(
|
||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
||||
out += songsLike.getOrNull(i)
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||
}
|
||||
@@ -881,16 +1210,44 @@ private fun buildOnlineRow(
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
||||
* mixes the server generated, no longer capped to the 3 carousel slots.
|
||||
*
|
||||
* Online: every generated mix as a real card; when none exist yet, a few
|
||||
* placeholders so the building / seed-needed state stays visible.
|
||||
* Offline: the cached mixes only (fully-cached first, greyed after), and
|
||||
* an empty list — hiding the whole section — when nothing is cached.
|
||||
*/
|
||||
internal fun buildSongsLikeRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
): List<PlaylistRowItem> {
|
||||
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
|
||||
return when {
|
||||
offline -> {
|
||||
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||
(available + greyed).map { PlaylistRowItem.Real(it) }
|
||||
}
|
||||
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
|
||||
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
|
||||
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||
* only differs by dropping placeholders + partitioning available-first.
|
||||
* Songs-like is excluded here too — it renders in its own row via
|
||||
* [buildSongsLikeRow] in both online and offline modes (#1491).
|
||||
*/
|
||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||
val out = mutableListOf<PlaylistRef>()
|
||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||
}
|
||||
@@ -905,7 +1262,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||
else -> "pending"
|
||||
}
|
||||
|
||||
private const val SONGS_LIKE_SLOTS = 3
|
||||
// How many "Songs like…" placeholder cards the dedicated row shows while
|
||||
// the mixes haven't generated yet (building / seed-needed). Real mixes,
|
||||
// once generated, are shown in full and no longer capped by this (#1491).
|
||||
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
|
||||
|
||||
/**
|
||||
* The 5 system playlist kinds the server generates that aren't pinned
|
||||
|
||||
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
@@ -116,9 +116,10 @@ private fun AlbumDetailStateContent(
|
||||
when (val s = state) {
|
||||
is AlbumDetailUiState.Loading ->
|
||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||
is AlbumDetailUiState.Error -> EmptyState(
|
||||
is AlbumDetailUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load album",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is AlbumDetailUiState.Success -> {
|
||||
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||
@@ -36,6 +38,7 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
private val likes: LikesRepository,
|
||||
private val player: PlayerController,
|
||||
private val seedCache: DetailSeedCache,
|
||||
networkStatus: NetworkStatusController,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -64,6 +67,14 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||
// load while the server was unreachable re-fetches when health
|
||||
// returns, so coming back to the screen shows content, not an error.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is AlbumDetailUiState.Error) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
|
||||
@@ -56,7 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
@@ -107,9 +107,10 @@ fun ArtistDetailScreen(
|
||||
} else {
|
||||
SkeletonArtistAlbumsGrid()
|
||||
}
|
||||
is ArtistDetailUiState.Error -> EmptyState(
|
||||
is ArtistDetailUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load artist",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is ArtistDetailUiState.Success ->
|
||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||
|
||||
+16
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
@@ -45,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
private val likes: LikesRepository,
|
||||
private val player: PlayerController,
|
||||
private val seedCache: DetailSeedCache,
|
||||
networkStatus: NetworkStatusController,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -69,6 +72,19 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245). From Error, redo the
|
||||
// whole load; from Success, re-pull just the secondary sections —
|
||||
// their fetch failures are swallowed to emptyList, so a dead zone
|
||||
// during the first load leaves them permanently absent otherwise.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
when (internal.value) {
|
||||
is ArtistDetailUiState.Error -> refresh()
|
||||
is ArtistDetailUiState.Success -> loadSecondarySections()
|
||||
is ArtistDetailUiState.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleLikeArtist() {
|
||||
|
||||
@@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor(
|
||||
combine(
|
||||
repository.observeArtists(),
|
||||
repository.observeAlbums(),
|
||||
) { artists, albums ->
|
||||
if (artists.isEmpty() && albums.isEmpty()) {
|
||||
UiState.Empty
|
||||
} else {
|
||||
syncController.lastSyncError,
|
||||
) { artists, albums, syncError ->
|
||||
when {
|
||||
artists.isNotEmpty() || albums.isNotEmpty() ->
|
||||
UiState.Success(LibraryData(artists, albums))
|
||||
// Empty cache + failed sync is a load failure, not an empty
|
||||
// library — surface it so the user gets a Retry instead of
|
||||
// the welcome copy. Heals automatically: SyncController
|
||||
// re-syncs on health recovery, clearing lastSyncError.
|
||||
syncError != null -> UiState.Error(syncError)
|
||||
else -> UiState.Empty
|
||||
}
|
||||
}
|
||||
.asCacheFirstStateFlow(viewModelScope)
|
||||
|
||||
@@ -25,6 +25,9 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||
@@ -38,6 +41,7 @@ import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -46,6 +50,7 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -68,14 +73,25 @@ data class LikedSections(
|
||||
class LikedTabViewModel @Inject constructor(
|
||||
private val repository: LikesRepository,
|
||||
private val player: PlayerController,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Copy for the most recent refresh failure; null once one succeeds. */
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||
// health returns, so a load that failed offline heals unprompted.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
refreshError.value = null
|
||||
runCatching { repository.refreshIds() }
|
||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,9 +108,16 @@ class LikedTabViewModel @Inject constructor(
|
||||
repository.observeLikedArtists(),
|
||||
repository.observeLikedAlbums(),
|
||||
repository.observeLikedTracks(),
|
||||
) { artists, albums, tracks ->
|
||||
refreshError,
|
||||
) { artists, albums, tracks, err ->
|
||||
val sections = LikedSections(artists, albums, tracks)
|
||||
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
|
||||
when {
|
||||
!sections.isAllEmpty -> UiState.Success(sections)
|
||||
// Empty cache + failed refresh is a load failure, not "no
|
||||
// likes yet" — surface Error so the user gets a Retry.
|
||||
err != null -> UiState.Error(err)
|
||||
else -> UiState.Empty
|
||||
}
|
||||
}.asCacheFirstStateFlow(viewModelScope)
|
||||
|
||||
/**
|
||||
@@ -130,9 +153,10 @@ fun LikedTab(
|
||||
body = "Tap the heart on an artist, album, or track to start " +
|
||||
"building your liked collection.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load likes",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is UiState.Success -> LikedContent(
|
||||
sections = s.data,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.fabledsword.minstrel.models
|
||||
|
||||
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
|
||||
data class AdminTagSourceRef(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val requiresApiKey: Boolean,
|
||||
val supports: List<String>,
|
||||
val enabled: Boolean,
|
||||
val apiKeySet: Boolean,
|
||||
val testable: Boolean,
|
||||
)
|
||||
|
||||
/** Result of a provider "test connection" call. */
|
||||
data class TagSourceTestResult(
|
||||
val ok: Boolean,
|
||||
val durationMs: Long = 0,
|
||||
val error: String = "",
|
||||
)
|
||||
@@ -16,4 +16,11 @@ data class UserRef(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val isAdmin: Boolean = false,
|
||||
/**
|
||||
* Account-level diagnostics opt-in (M9). NOT present in the login
|
||||
* response (that's the narrow UserView) — populated by the /api/me
|
||||
* refresh in AuthController so a remote admin flip reaches the
|
||||
* device. Default false until the first refresh.
|
||||
*/
|
||||
val debugModeEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.fabledsword.minstrel.models.wire
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
|
||||
* Every field defaults defensively so a missing JSON key never crashes
|
||||
* deserialization. `versionBumped` is present only on the PATCH response.
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminTagSourceWire(
|
||||
val id: String = "",
|
||||
@SerialName("display_name") val displayName: String = "",
|
||||
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
|
||||
val supports: List<String> = emptyList(),
|
||||
val enabled: Boolean = false,
|
||||
@SerialName("api_key_set") val apiKeySet: Boolean = false,
|
||||
@SerialName("display_order") val displayOrder: Int = 0,
|
||||
val testable: Boolean = false,
|
||||
@SerialName("version_bumped") val versionBumped: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminTagSourcesListWire(
|
||||
val providers: List<AdminTagSourceWire> = emptyList(),
|
||||
@SerialName("sources_version") val sourcesVersion: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TestTagSourceWire(
|
||||
val ok: Boolean = false,
|
||||
@SerialName("duration_ms") val durationMs: Long = 0,
|
||||
val error: String = "",
|
||||
)
|
||||
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
val source: String? = null,
|
||||
// #1551: device class for context conditioning (server normalizes).
|
||||
@SerialName("device_class") val deviceClass: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
|
||||
val at: String,
|
||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||
val source: String? = null,
|
||||
// #1551: device class for context conditioning (server normalizes).
|
||||
@SerialName("device_class") val deviceClass: String? = null,
|
||||
)
|
||||
|
||||
@@ -18,4 +18,5 @@ data class MyProfileWire(
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
val email: String? = null,
|
||||
@SerialName("is_admin") val isAdmin: Boolean = false,
|
||||
@SerialName("debug_mode_enabled") val debugModeEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
|
||||
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
||||
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
||||
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
||||
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
|
||||
}
|
||||
}
|
||||
}
|
||||
composable<AdminTagSources> {
|
||||
WithAnimatedScope {
|
||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||
AdminTagSourcesScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable data object AdminRequests
|
||||
@Serializable data object AdminQuarantine
|
||||
@Serializable data object AdminUsers
|
||||
@Serializable data object AdminTagSources
|
||||
|
||||
// ── Outside-shell full-screen routes ──────────────────────────────────
|
||||
|
||||
|
||||
+106
-24
@@ -17,6 +17,7 @@ import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import java.io.IOException
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -58,8 +59,10 @@ import timber.log.Timber
|
||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||
* advance natively. PollLoop syncs the local cursor by comparing the
|
||||
* 1-based Track index from GetPositionInfo.
|
||||
* advance natively. PollLoop keeps the local cursor aligned to the
|
||||
* track the renderer is actually playing, matched by track identity
|
||||
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
|
||||
* index drift -- see [syncLocalCursorToRemote].
|
||||
*/
|
||||
class MinstrelForwardingPlayer(
|
||||
private val delegate: Player,
|
||||
@@ -339,6 +342,23 @@ class MinstrelForwardingPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
// The system media notification / lock-screen / Android Auto / Wear 'next'
|
||||
// and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
|
||||
// -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
|
||||
// *MediaItem variants the in-app transport buttons call. Un-overridden, the
|
||||
// base ForwardingPlayer forwards these to the paused local delegate -- so
|
||||
// the notification next/prev only nudged the local cursor (which the poll
|
||||
// then re-synced back to Sonos), reading as dead buttons while casting.
|
||||
// Route them through the same Sonos path as the media-item variants when a
|
||||
// UPnP route is engaged; plain local playback keeps the default behaviour.
|
||||
override fun seekToNext() {
|
||||
if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
|
||||
}
|
||||
|
||||
override fun seekToPrevious() {
|
||||
if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
|
||||
}
|
||||
|
||||
override fun getCurrentPosition(): Long =
|
||||
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
||||
|
||||
@@ -526,18 +546,8 @@ class MinstrelForwardingPlayer(
|
||||
|
||||
/**
|
||||
* One poll tick: read position + transport state from Sonos, apply to
|
||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||
* index when not in queue load.
|
||||
*
|
||||
* Cursor sync is gated on `holder.target == null` (= not loading)
|
||||
* because during load Sonos reports Track=1 while we're still
|
||||
* appending, and syncing would race the SetAV+Seek that lands
|
||||
* after. Outside load, forward sync catches Sonos auto-advances
|
||||
* (queue end-of-track), Sonos-app driven Next presses, and any
|
||||
* drift after a brief poll-failure burst that didn't trip the
|
||||
* drop threshold. Forward-only because a Next override we just
|
||||
* issued can race with a poll still reporting the prior Track --
|
||||
* the next poll catches up safely.
|
||||
* [remoteState], and sync the local cursor to the track the renderer is
|
||||
* actually playing (see [syncLocalCursorToRemote]).
|
||||
*/
|
||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||
val info = active.avTransport.getPositionInfo()
|
||||
@@ -553,7 +563,7 @@ class MinstrelForwardingPlayer(
|
||||
trackUri = info.trackUri,
|
||||
trackNumber = info.track,
|
||||
)
|
||||
maybeSyncLocalCursor(info.track)
|
||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||
val transport = active.avTransport.getTransportInfo()
|
||||
when (transport.state) {
|
||||
TransportState.PLAYING -> {
|
||||
@@ -577,23 +587,95 @@ class MinstrelForwardingPlayer(
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
|
||||
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
||||
if (holder.target.value != null) return
|
||||
if (sonosTrack <= 0) return
|
||||
val sonosIdx = sonosTrack - 1
|
||||
/**
|
||||
* Align the paused local delegate cursor to the track the renderer is
|
||||
* actually playing, so the un-overridden current-item getters
|
||||
* (getCurrentMediaItem/Index -- what both the in-app player UI via
|
||||
* PlayerController.onEvents AND the MediaSession notification read) always
|
||||
* resolve to that track. This is THE single source of truth for "what's
|
||||
* playing" during a cast.
|
||||
*
|
||||
* Identity-first: match the track-id embedded in the renderer's current URI
|
||||
* against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
|
||||
* TrackRef.id), so the index-offset wobble across queue reloads / re-casts
|
||||
* can never park the cursor on a stale (pre-cast) track. Falls back to the
|
||||
* 1-based Sonos Track index only when the URI can't be resolved to a queue
|
||||
* item. Unlike the old forward-only sync this moves the cursor in BOTH
|
||||
* directions -- a renderer Previous / re-cast to a lower track pulls it back
|
||||
* too. Runs on the application looper (delegate access contract).
|
||||
*
|
||||
* Suppressed while:
|
||||
* - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
|
||||
* (re)uploaded, so its Track index is meaningless; and
|
||||
* - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
|
||||
* the override already advanced the delegate optimistically, and Sonos
|
||||
* still reports the OLD track for a beat, so syncing now would undo the
|
||||
* user's press. The pending deadline is honoured directly so a missed
|
||||
* clear can't wedge the sync.
|
||||
*/
|
||||
private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
|
||||
if (isLoadingUpnp()) return
|
||||
val pendingDeadline = remoteState.pendingTransportDeadlineMs
|
||||
if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
|
||||
handler.post {
|
||||
val localIdx = delegate.currentMediaItemIndex
|
||||
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
||||
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
|
||||
if (target != delegate.currentMediaItemIndex) {
|
||||
Timber.w(
|
||||
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
||||
localIdx, sonosIdx,
|
||||
"UPnP cursor sync: local=%d -> %d",
|
||||
delegate.currentMediaItemIndex, target,
|
||||
)
|
||||
delegate.seekTo(sonosIdx, 0L)
|
||||
delegate.seekTo(target, 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Most-authoritative -> most-degraded (project rule #48): the track-id
|
||||
* parsed from the renderer's current URI wins; else the 1-based Sonos Track
|
||||
* index; else null (leave the cursor put rather than surface a wrong track).
|
||||
* Must run on the application looper -- reads the delegate timeline.
|
||||
*/
|
||||
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
|
||||
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
|
||||
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
|
||||
}
|
||||
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
|
||||
* occurrence nearest [preferNear] so a track that appears twice in the queue
|
||||
* doesn't snap the cursor across the queue. null if the id isn't present.
|
||||
*/
|
||||
private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
|
||||
var best: Int? = null
|
||||
var bestDist = Int.MAX_VALUE
|
||||
for (i in 0 until delegate.mediaItemCount) {
|
||||
if (delegate.getMediaItemAt(i).mediaId == mediaId) {
|
||||
val dist = abs(i - preferNear)
|
||||
if (dist < bestDist) {
|
||||
best = i
|
||||
bestDist = dist
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
|
||||
private fun trackIdFromStreamUri(uri: String): String? {
|
||||
val start = uri.indexOf(TRACKS_PATH_MARKER)
|
||||
if (start < 0) return null
|
||||
val idStart = start + TRACKS_PATH_MARKER.length
|
||||
val idEnd = uri.indexOf('/', idStart)
|
||||
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Path segment that precedes the track-id in a stream URI
|
||||
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
|
||||
// back to a delegate MediaItem by identity.
|
||||
const val TRACKS_PATH_MARKER = "/api/tracks/"
|
||||
const val POLL_INTERVAL_MS = 1_000L
|
||||
const val NON_PLAYING_CONFIRM = 2
|
||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||
|
||||
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
trackId = trackId,
|
||||
clientId = cid,
|
||||
source = source.takeIf { !it.isNullOrEmpty() },
|
||||
deviceClass = DEVICE_CLASS,
|
||||
),
|
||||
)
|
||||
if (curTrackId == trackId) {
|
||||
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
atIso = startedAt.toString(),
|
||||
durationPlayedMs = durationPlayedMs,
|
||||
source = source.takeIf { !it.isNullOrEmpty() },
|
||||
deviceClass = DEVICE_CLASS,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
|
||||
|
||||
// ── Lifecycle: durable-close on app background / detach ────────
|
||||
|
||||
companion object {
|
||||
// #1551: Android is a mobile client. Refine to tablet/tv via device
|
||||
// configuration later if the metrics trend view shows it matters.
|
||||
private const val DEVICE_CLASS = "mobile"
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||
// alone: the foreground media service keeps the process — and this
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -63,6 +65,7 @@ class PlayerController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val radio: RadioController,
|
||||
private val playlists: PlaylistsRepository,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
@@ -111,6 +114,13 @@ class PlayerController @Inject constructor(
|
||||
*/
|
||||
private var lastEvaluatedItemIndex: Int = -1
|
||||
|
||||
/**
|
||||
* #968: self-heal budget — re-pulls of a stale refreshable queue since
|
||||
* the last successful play. Capped so a still-broken refresh can't loop;
|
||||
* reset when any track loads with real audio.
|
||||
*/
|
||||
private var selfHealAttempts = 0
|
||||
|
||||
/**
|
||||
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||
* the player's own getMediaItem(index) returns Media3 types; we keep
|
||||
@@ -278,6 +288,37 @@ class PlayerController @Inject constructor(
|
||||
controller.addMediaItem(track.toMediaItem(source = null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder the queue: move the item at [from] to [to], keeping the domain
|
||||
* snapshot in lock-step with the player's MediaItem timeline. Media3 emits
|
||||
* onEvents → uiState reflects the new order (and the still-playing item's
|
||||
* index). No-op on bad indices or a no-move.
|
||||
*/
|
||||
fun moveInQueue(from: Int, to: Int) {
|
||||
val controller = mediaController ?: return
|
||||
if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
|
||||
queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
|
||||
controller.moveMediaItem(from, to)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the queue item at [index]. When it's the currently-playing item
|
||||
* Media3 advances to the next automatically. No-op on a bad index.
|
||||
*/
|
||||
fun removeFromQueue(index: Int) {
|
||||
val controller = mediaController ?: return
|
||||
if (index !in queueRefs.indices) return
|
||||
queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
|
||||
controller.removeMediaItem(index)
|
||||
}
|
||||
|
||||
/** Empty the queue and stop playback. */
|
||||
fun clearQueue() {
|
||||
val controller = mediaController ?: return
|
||||
queueRefs = emptyList()
|
||||
controller.clearMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
||||
* "radio:<id>" so the server-side rotation reporter can
|
||||
@@ -351,7 +392,12 @@ class PlayerController @Inject constructor(
|
||||
if (current == null || upnpEngaged) return
|
||||
val duration = controller.duration
|
||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||
if (!isZeroDuration) return
|
||||
if (!isZeroDuration) {
|
||||
// A track loaded with real audio — clear the self-heal budget so
|
||||
// a later stale queue can recover again.
|
||||
selfHealAttempts = 0
|
||||
return
|
||||
}
|
||||
playbackErrorEventsChannel.trySend(
|
||||
PlaybackErrorEvent(
|
||||
trackId = current.id,
|
||||
@@ -367,6 +413,58 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #968: the queue is fully unplayable (every track failed to load). If it
|
||||
* came from a refreshable source — a system playlist (bare-variant source)
|
||||
* or radio ("radio:<seed>") — the snapshot is probably stale (app/tab left
|
||||
* open across the daily rebuild); re-pull it and resume instead of stopping.
|
||||
* Bounded by [selfHealAttempts]. Album / artist / user-playlist / offline
|
||||
* sources have nothing to refresh — stop.
|
||||
*/
|
||||
private fun selfHealOrStop(controller: MediaController) {
|
||||
val source = controller.currentMediaItem
|
||||
?.mediaMetadata?.extras?.getString(MINSTREL_SOURCE_KEY)
|
||||
if (source == null || selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) {
|
||||
controller.stop()
|
||||
return
|
||||
}
|
||||
when {
|
||||
source.startsWith("radio:") -> {
|
||||
selfHealAttempts++
|
||||
scope.launch { selfHealRadio(source.removePrefix("radio:")) }
|
||||
}
|
||||
!source.contains(':') -> { // bare variant = refreshable system playlist
|
||||
selfHealAttempts++
|
||||
scope.launch { selfHealSystem(source) }
|
||||
}
|
||||
else -> controller.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun selfHealSystem(variant: String) {
|
||||
val refs = runCatching { playlists.systemShuffle(variant).tracks.toPlayableTrackRefs() }
|
||||
.getOrDefault(emptyList())
|
||||
if (refs.isEmpty()) {
|
||||
stopOnControllerThread()
|
||||
return
|
||||
}
|
||||
setQueue(refs, initialIndex = 0, source = variant)
|
||||
}
|
||||
|
||||
private suspend fun selfHealRadio(seedTrackId: String) {
|
||||
val refs = runCatching { radio.seed(seedTrackId) }.getOrDefault(emptyList())
|
||||
if (refs.isEmpty()) {
|
||||
stopOnControllerThread()
|
||||
return
|
||||
}
|
||||
setQueue(refs, initialIndex = 0, source = "radio:$seedTrackId")
|
||||
}
|
||||
|
||||
private fun stopOnControllerThread() {
|
||||
val controller = mediaController ?: return
|
||||
runOnControllerThread(controller) { controller.stop() }
|
||||
}
|
||||
|
||||
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||
|
||||
private suspend fun connectAndObserve() {
|
||||
@@ -417,6 +515,17 @@ class PlayerController @Inject constructor(
|
||||
detail = error.message,
|
||||
),
|
||||
)
|
||||
// A failed load leaves the player IDLE; advance past the bad
|
||||
// track and re-prepare so one unplayable item doesn't strand
|
||||
// playback (mirrors the zero_duration skip). At the end of a
|
||||
// fully-unplayable queue, try to self-heal a stale refreshable
|
||||
// source before stopping.
|
||||
if (controller.hasNextMediaItem()) {
|
||||
controller.seekToNextMediaItem()
|
||||
controller.prepare()
|
||||
} else {
|
||||
selfHealOrStop(controller)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
@@ -520,18 +629,19 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* One position-polling tick. Owns track-change detection too: when UPnP
|
||||
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
||||
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
||||
* uiState.queueIndex stuck on the old track even after Sonos has
|
||||
* advanced. So the tick reads Sonos's reported Track as the source of
|
||||
* truth, rebuilds the index/title fields itself, and force-syncs the
|
||||
* wrapped player as defense in depth.
|
||||
* One position-polling tick. Patches ONLY the smoothly-changing transport
|
||||
* fields (position / duration / play-pause / buffer) via `.copy()`. The
|
||||
* queue index + current track are owned solely by [onEvents], which reads
|
||||
* the authoritative delegate cursor: during a cast,
|
||||
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
|
||||
* the track the renderer is actually playing (matched by identity), so there
|
||||
* is exactly one writer of "what's playing" and a stale tick can never
|
||||
* revert it to the old track. (Previously the tick was a second index writer
|
||||
* that fought onEvents — the flicker-to-stale-track bug.)
|
||||
*/
|
||||
private fun tickPositionPoll(controller: MediaController) {
|
||||
val upnpActive = activeUpnpHolder.active.value != null
|
||||
resolvePendingTransport(controller, upnpActive)
|
||||
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
|
||||
val effectiveIsPlaying =
|
||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||
val effectivePosition = if (upnpActive) {
|
||||
@@ -539,28 +649,30 @@ class PlayerController @Inject constructor(
|
||||
} else {
|
||||
controller.currentPosition
|
||||
}
|
||||
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
||||
val idx = controller.currentMediaItemIndex
|
||||
val current = uiStateInternal.value
|
||||
val newPos = effectivePosition.coerceAtLeast(0)
|
||||
val newDur = effectiveDuration(
|
||||
upnpActive,
|
||||
remoteState.durationMs,
|
||||
controller.duration,
|
||||
desiredIdx = desiredIdx,
|
||||
controllerIdx = controller.currentMediaItemIndex,
|
||||
desiredIdx = idx,
|
||||
controllerIdx = idx,
|
||||
)
|
||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||
// Track adjustments are forward-only AND suppressed while a user
|
||||
// transport press is pending Sonos confirmation. Together those keep
|
||||
// either direction of user input from being undone by a stale poll.
|
||||
val trackChanged = !pendingTransport &&
|
||||
desiredIdx > current.queueIndex &&
|
||||
desiredIdx in queueRefs.indices
|
||||
publishTickIfChanged(
|
||||
current, trackChanged, desiredIdx,
|
||||
effectiveIsPlaying, newPos, newDur, newBuf,
|
||||
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
|
||||
current.positionMs != newPos ||
|
||||
current.durationMs != newDur ||
|
||||
current.bufferedPositionMs != newBuf
|
||||
if (somethingChanged) {
|
||||
uiStateInternal.value = current.copy(
|
||||
isPlaying = effectiveIsPlaying,
|
||||
positionMs = newPos,
|
||||
durationMs = newDur,
|
||||
bufferedPositionMs = newBuf,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven primary path: clear pending the moment Sonos's reported
|
||||
@@ -576,48 +688,6 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
|
||||
private fun publishTickIfChanged(
|
||||
current: PlayerUiState,
|
||||
trackChanged: Boolean,
|
||||
desiredIdx: Int,
|
||||
effectiveIsPlaying: Boolean,
|
||||
newPos: Long,
|
||||
newDur: Long,
|
||||
newBuf: Long,
|
||||
) {
|
||||
val somethingChanged = trackChanged ||
|
||||
current.isPlaying != effectiveIsPlaying ||
|
||||
current.positionMs != newPos ||
|
||||
current.durationMs != newDur
|
||||
if (!somethingChanged) return
|
||||
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
|
||||
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
|
||||
uiStateInternal.value = current.copy(
|
||||
currentTrack = newTrack,
|
||||
queueIndex = newIdx,
|
||||
isPlaying = effectiveIsPlaying,
|
||||
positionMs = newPos,
|
||||
durationMs = newDur,
|
||||
bufferedPositionMs = newBuf,
|
||||
)
|
||||
// Intentionally do NOT call controller.seekTo here. That would route
|
||||
// through MinstrelForwardingPlayer's seekTo override and re-issue
|
||||
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
|
||||
// start of the same track it's already playing, restarting the song.
|
||||
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
|
||||
// delegate.seekTo (which bypasses the override). If it lags briefly,
|
||||
// the next pollOnce catches up; the uiState above already reflects
|
||||
// Sonos's truth for the user.
|
||||
}
|
||||
|
||||
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
|
||||
if (upnpActive) {
|
||||
(remoteState.trackNumber - 1).coerceAtLeast(0)
|
||||
} else {
|
||||
controller.currentMediaItemIndex
|
||||
}
|
||||
|
||||
// ── Remote position interpolation state ──────────────────────────────
|
||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
||||
@@ -763,6 +833,10 @@ class PlayerController @Inject constructor(
|
||||
// they don't need this poll.
|
||||
private const val POSITION_POLL_INTERVAL_MS = 500L
|
||||
|
||||
// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
|
||||
// the next successful play) so a still-broken refresh can't loop.
|
||||
private const val MAX_SELF_HEAL_ATTEMPTS = 1
|
||||
|
||||
/**
|
||||
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||
|
||||
+6
-3
@@ -133,7 +133,7 @@ private fun RouteRow(
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = route.description ?: defaultSubtitle(route)
|
||||
val subtitle = route.description ?: defaultSubtitle(route, isSelected)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -198,10 +198,13 @@ private fun MulticastHintRow() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
||||
// isSelected (route == the active/selected route) drives the Bluetooth
|
||||
// "Connected" subtitle. RouteInfo.connectionState can't — it stays
|
||||
// DISCONNECTED for local SYSTEM routes even when they're in use.
|
||||
private fun defaultSubtitle(route: OutputRoute, isSelected: Boolean): String = when (route.kind) {
|
||||
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
||||
OutputRoute.Kind.Wired -> "Wired"
|
||||
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
|
||||
OutputRoute.Kind.Bluetooth -> if (isSelected) "Connected" else "Available"
|
||||
OutputRoute.Kind.Cast -> "Cast"
|
||||
OutputRoute.Kind.Other -> "Available"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ data class OutputRoute(
|
||||
val description: String?,
|
||||
val kind: Kind,
|
||||
val protocol: Protocol,
|
||||
val isConnected: Boolean,
|
||||
) {
|
||||
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
||||
|
||||
@@ -43,9 +42,11 @@ data class OutputRoute(
|
||||
/**
|
||||
* Lift a MediaRouter [route] into the domain model. Kind is
|
||||
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
||||
* device types fall through to [Kind.Other]. The
|
||||
* `connectionState` proxy is good enough for the chip's
|
||||
* "Connected"/"Available" subtitle.
|
||||
* device types fall through to [Kind.Other]. "Active" is NOT an
|
||||
* intrinsic of the route — the caller derives it by comparing to
|
||||
* the selected route (RouteSnapshot.current), because
|
||||
* RouteInfo.connectionState only reflects remote-route handshakes
|
||||
* and stays DISCONNECTED for local SYSTEM routes even when in use.
|
||||
*/
|
||||
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
||||
val kind = when (route.deviceType) {
|
||||
@@ -58,15 +59,12 @@ data class OutputRoute(
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
||||
else -> Kind.Other
|
||||
}
|
||||
val connected =
|
||||
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
|
||||
return OutputRoute(
|
||||
id = route.id,
|
||||
name = route.name,
|
||||
description = route.description,
|
||||
kind = kind,
|
||||
protocol = Protocol.SYSTEM,
|
||||
isConnected = connected,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,10 +74,10 @@ data class OutputRoute(
|
||||
* network speakers into the same `OutputPickerController`
|
||||
* routes stream the system routes come through.
|
||||
*
|
||||
* `isConnected = false` because UPnP devices have no
|
||||
* MediaRouter connection-state concept — they're always
|
||||
* "available" on the LAN, and the picker's selected-route
|
||||
* rendering handles the "currently playing" indicator.
|
||||
* UPnP devices have no MediaRouter connection-state concept —
|
||||
* they're always "available" on the LAN, and the picker's
|
||||
* selected-route rendering handles the "currently playing"
|
||||
* indicator.
|
||||
*
|
||||
* Subtitle is `manufacturer modelName` joined by a single
|
||||
* space, falling back to "Network speaker" when both fields
|
||||
@@ -96,7 +94,6 @@ data class OutputRoute(
|
||||
description = description,
|
||||
kind = Kind.Other,
|
||||
protocol = Protocol.UPNP,
|
||||
isConnected = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.player.PlayerUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -20,13 +25,29 @@ import javax.inject.Inject
|
||||
* stub-test for ViewModel-level logic when it grows).
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
|
||||
class PlayerViewModel @Inject constructor(
|
||||
private val controller: PlayerController,
|
||||
private val likes: LikesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||
val dropEvents: SharedFlow<String> = controller.dropEvents
|
||||
|
||||
/**
|
||||
* Reactive set of liked track ids — the set-based pattern the queue
|
||||
* (and playlist/album/artist detail) uses to color each row's
|
||||
* [com.fabledsword.minstrel.shared.widgets.LikeButton] without a
|
||||
* per-row Flow subscription.
|
||||
*/
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
fun play() = controller.play()
|
||||
fun pause() = controller.pause()
|
||||
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
||||
@@ -35,4 +56,16 @@ class PlayerViewModel @Inject constructor(
|
||||
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
||||
fun toggleShuffle() = controller.toggleShuffle()
|
||||
fun cycleRepeat() = controller.cycleRepeat()
|
||||
fun moveInQueue(from: Int, to: Int) = controller.moveInQueue(from, to)
|
||||
fun removeFromQueue(index: Int) = controller.removeFromQueue(index)
|
||||
fun clearQueue() = controller.clearQueue()
|
||||
|
||||
fun toggleLikeTrack(trackId: String) {
|
||||
val desired = trackId !in likedTrackIds.value
|
||||
viewModelScope.launch {
|
||||
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -20,22 +28,43 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.ArrowDown
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.GripVertical
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.composables.icons.lucide.Trash2
|
||||
import com.composables.icons.lucide.Volume2
|
||||
import com.composables.icons.lucide.X
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -44,16 +73,35 @@ fun QueueScreen(
|
||||
viewModel: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Queue") },
|
||||
title = {
|
||||
Column {
|
||||
Text("Queue")
|
||||
if (state.queue.isNotEmpty()) {
|
||||
Text(
|
||||
text = queueSummary(state.queue),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (state.queue.isNotEmpty()) {
|
||||
IconButton(onClick = viewModel::clearQueue) {
|
||||
Icon(Lucide.Trash2, contentDescription = "Clear queue")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
@@ -67,33 +115,109 @@ fun QueueScreen(
|
||||
QueueList(
|
||||
tracks = state.queue,
|
||||
currentIndex = state.queueIndex,
|
||||
likedTrackIds = likedTrackIds,
|
||||
onJumpTo = viewModel::seekToIndex,
|
||||
onToggleLike = viewModel::toggleLikeTrack,
|
||||
onMove = viewModel::moveInQueue,
|
||||
onRemove = viewModel::removeFromQueue,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
private fun QueueList(
|
||||
tracks: List<TrackRef>,
|
||||
currentIndex: Int,
|
||||
likedTrackIds: Set<String>,
|
||||
onJumpTo: (Int) -> Unit,
|
||||
onToggleLike: (String) -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onRemove: (Int) -> Unit,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
val listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Follow the now-playing row as the track auto-advances, but only while it's
|
||||
// near the visible window — if the user has scrolled away to browse, leave
|
||||
// them there (the pill offers the way back). Parity with the web queue.
|
||||
LaunchedEffect(currentIndex) {
|
||||
if (currentIndex < 0) return@LaunchedEffect
|
||||
val visible = listState.layoutInfo.visibleItemsInfo
|
||||
val first = visible.firstOrNull()?.index ?: 0
|
||||
val last = visible.lastOrNull()?.index ?: 0
|
||||
if (currentIndex in (first - 1)..(last + 1)) {
|
||||
listState.animateScrollToItem(currentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
val currentVisible by remember {
|
||||
derivedStateOf {
|
||||
listState.layoutInfo.visibleItemsInfo.any { it.index == currentIndex }
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||
QueueRow(
|
||||
track = track,
|
||||
index = index,
|
||||
queueSize = tracks.size,
|
||||
isCurrent = index == currentIndex,
|
||||
liked = track.id in likedTrackIds,
|
||||
onClick = { onJumpTo(index) },
|
||||
onToggleLike = { onToggleLike(track.id) },
|
||||
onRemove = { onRemove(index) },
|
||||
onMove = onMove,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
JumpToCurrentPill(
|
||||
visible = currentIndex >= 0 && !currentVisible,
|
||||
onClick = {
|
||||
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
||||
private fun JumpToCurrentPill(
|
||||
visible: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, modifier = modifier) {
|
||||
FilledTonalButton(onClick = onClick) {
|
||||
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Jump to current")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
private fun QueueRow(
|
||||
track: TrackRef,
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
isCurrent: Boolean,
|
||||
liked: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
) {
|
||||
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by remember { mutableIntStateOf(0) }
|
||||
val highlight = if (isCurrent) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||
} else {
|
||||
@@ -102,12 +226,23 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { rowHeightPx = it.height }
|
||||
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||
.graphicsLayer { translationY = dragOffsetY }
|
||||
.background(highlight)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
DragHandle(
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
rowHeightPx = rowHeightPx,
|
||||
onOffsetChange = { dragOffsetY = it },
|
||||
onMove = onMove,
|
||||
)
|
||||
QueueRowThumbnail(track = track)
|
||||
if (isCurrent) {
|
||||
Icon(
|
||||
Lucide.Volume2,
|
||||
@@ -115,7 +250,85 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
||||
if (track.durationSec > 0) {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(Lucide.X, contentDescription = "Remove from queue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DragHandle(
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
rowHeightPx: Int,
|
||||
onOffsetChange: (Float) -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
) {
|
||||
// Mirrors the web queue: the row follows the finger during a drag, then on
|
||||
// release we translate the accumulated offset into a row delta and reorder.
|
||||
var offset by remember { mutableFloatStateOf(0f) }
|
||||
Icon(
|
||||
Lucide.GripVertical,
|
||||
contentDescription = "Reorder track",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.pointerInput(index, queueSize, rowHeightPx) {
|
||||
detectDragGestures(
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offset += dragAmount.y
|
||||
onOffsetChange(offset)
|
||||
},
|
||||
onDragEnd = {
|
||||
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
|
||||
val target = (index + delta).coerceIn(0, queueSize - 1)
|
||||
if (target != index) onMove(index, target)
|
||||
offset = 0f
|
||||
onOffsetChange(0f)
|
||||
},
|
||||
onDragCancel = {
|
||||
offset = 0f
|
||||
onOffsetChange(0f)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRowThumbnail(track: TrackRef) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ServerImage(
|
||||
url = track.coverUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
Lucide.Music,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
@@ -135,14 +348,6 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (track.durationSec > 0) {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Artist · Album" — collapses gracefully when either is missing. */
|
||||
@@ -150,4 +355,18 @@ private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, tr
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" · ")
|
||||
|
||||
/** "N tracks · 12 min" header summary. */
|
||||
private fun queueSummary(tracks: List<TrackRef>): String {
|
||||
val minutes = tracks.sumOf { it.durationSec } / SECONDS_PER_MINUTE
|
||||
val length = if (minutes >= MINUTES_PER_HOUR) {
|
||||
"${minutes / MINUTES_PER_HOUR}h ${minutes % MINUTES_PER_HOUR}m"
|
||||
} else {
|
||||
"$minutes min"
|
||||
}
|
||||
val noun = if (tracks.size == 1) "track" else "tracks"
|
||||
return "${tracks.size} $noun · $length"
|
||||
}
|
||||
|
||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||
private const val SECONDS_PER_MINUTE = 60
|
||||
private const val MINUTES_PER_HOUR = 60
|
||||
|
||||
+85
-30
@@ -28,15 +28,17 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -56,6 +58,8 @@ import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.RefreshCw
|
||||
import com.composables.icons.lucide.Shuffle
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.events.LiveEvent
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
@@ -72,6 +76,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
||||
@@ -113,6 +118,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
private val player: PlayerController,
|
||||
private val eventsStream: EventsStream,
|
||||
private val seedCache: DetailSeedCache,
|
||||
networkStatus: NetworkStatusController,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -139,6 +145,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||
|
||||
/**
|
||||
* #980: a system-playlist rebuild landed (SSE) while this screen is open —
|
||||
* its list is now stale and the uuid has rotated. Drives a "this mix was
|
||||
* refreshed → Refresh" snackbar rather than yanking the user; Refresh
|
||||
* re-resolves the variant via [reloadRebuilt].
|
||||
*/
|
||||
private val staleInternal = MutableStateFlow(false)
|
||||
val stale: StateFlow<Boolean> = staleInternal.asStateFlow()
|
||||
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
@@ -154,6 +169,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
.filter { it.kind.startsWith("playlist.") }
|
||||
.collect { handlePlaylistEvent(it) }
|
||||
}
|
||||
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||
// load while the server was unreachable re-fetches when health
|
||||
// returns, instead of waiting for the Retry tap.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is PlaylistDetailUiState.Error) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +186,12 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
* ignored — the playlists-list screen handles those.
|
||||
*/
|
||||
private fun handlePlaylistEvent(event: LiveEvent) {
|
||||
// #980: a system rebuild carries no playlist_id (it rebuilds all of the
|
||||
// user's system mixes at once) — handle it before the per-id filter.
|
||||
if (event.kind == "playlist.system_rebuilt") {
|
||||
markStaleIfSystem()
|
||||
return
|
||||
}
|
||||
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
||||
if (eventPlaylistId != playlistId) return
|
||||
when (event.kind) {
|
||||
@@ -171,6 +200,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun markStaleIfSystem() {
|
||||
val playlist = (internal.value as? PlaylistDetailUiState.Success)
|
||||
?.detail?.playlist ?: return
|
||||
if (playlist.systemVariant?.takeIf { playlist.refreshable } != null) {
|
||||
staleInternal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleLikeTrack(trackId: String) {
|
||||
val desired = trackId !in likedTrackIds.value
|
||||
viewModelScope.launch {
|
||||
@@ -220,6 +257,30 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #980: user tapped Refresh on the stale-view snackbar. The rebuild
|
||||
* already ran server-side (uuid rotated) — re-resolve the variant to the
|
||||
* fresh playlist id and hand it to [regenerated] for navigate-replace.
|
||||
* Unlike [regenerate] this does NOT trigger another rebuild.
|
||||
*/
|
||||
fun reloadRebuilt() {
|
||||
val playlist = (internal.value as? PlaylistDetailUiState.Success)
|
||||
?.detail?.playlist ?: return
|
||||
val variant = playlist.systemVariant?.takeIf { playlist.refreshable } ?: return
|
||||
staleInternal.value = false
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.systemShuffle(variant).playlist.id }
|
||||
.onSuccess { newId ->
|
||||
if (newId.isNotEmpty()) regeneratedChannel.trySend(newId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** #980: user dismissed the stale-view snackbar without refreshing. */
|
||||
fun dismissStale() {
|
||||
staleInternal.value = false
|
||||
}
|
||||
|
||||
/** Play the available tracks starting at [startTrackId] (or first available). */
|
||||
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
||||
val refs = tracks.toPlayableTrackRefs()
|
||||
@@ -261,8 +322,25 @@ fun PlaylistDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
// #980: a rebuild landed while this system-playlist screen is open. Offer a
|
||||
// non-intrusive "refreshed → Refresh" snackbar; Refresh re-resolves the
|
||||
// rotated uuid (via the regenerated navigate-replace flow).
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val stale by viewModel.stale.collectAsState()
|
||||
LaunchedEffect(stale) {
|
||||
if (stale) {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = "This mix was refreshed",
|
||||
actionLabel = "Refresh",
|
||||
duration = SnackbarDuration.Indefinite,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) viewModel.reloadRebuilt()
|
||||
else viewModel.dismissStale()
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(currentTitle(state)) },
|
||||
@@ -301,7 +379,11 @@ private fun PlaylistDetailContent(
|
||||
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||
is PlaylistDetailUiState.Loading ->
|
||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
||||
is PlaylistDetailUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load playlist",
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is PlaylistDetailUiState.Success -> {
|
||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||
val playerState by playerViewModel.uiState.collectAsState()
|
||||
@@ -585,33 +667,6 @@ private fun SeededPlaylistLoading(seed: PlaylistRef) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
|
||||
var retried by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (!retried) {
|
||||
retried = true
|
||||
onRetry()
|
||||
}
|
||||
},
|
||||
) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private const val UNAVAILABLE_ALPHA = 0.4f
|
||||
|
||||
+25
-8
@@ -27,8 +27,10 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||
@@ -40,6 +42,7 @@ import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
@@ -47,8 +50,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
@@ -84,6 +89,9 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** Copy for the most recent list-refresh failure; null once one succeeds. */
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Live updates: a playlist created/updated/deleted from another
|
||||
@@ -93,11 +101,18 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
.filter { it.kind.startsWith("playlist.") }
|
||||
.collect { refresh() }
|
||||
}
|
||||
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||
// health returns, so a load that failed offline heals unprompted.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
refreshError.value = null
|
||||
runCatching { repository.refreshList() }
|
||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||
}
|
||||
|
||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
||||
@@ -110,12 +125,13 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||
repository.observeAll()
|
||||
.map { list ->
|
||||
if (list.isEmpty()) {
|
||||
UiState.Empty
|
||||
} else {
|
||||
UiState.Success(list)
|
||||
combine(repository.observeAll(), refreshError) { list, err ->
|
||||
when {
|
||||
list.isNotEmpty() -> UiState.Success(list)
|
||||
// Empty cache + failed refresh is a load failure, not "no
|
||||
// playlists yet" — surface Error so the user gets a Retry.
|
||||
err != null -> UiState.Error(err)
|
||||
else -> UiState.Empty
|
||||
}
|
||||
}
|
||||
.asCacheFirstStateFlow(viewModelScope)
|
||||
@@ -157,9 +173,10 @@ fun PlaylistsListScreen(
|
||||
"appear once your library has enough plays. Create your " +
|
||||
"own playlist from any album or track in the meantime.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load playlists",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is UiState.Success -> PlaylistsGrid(
|
||||
playlists = s.data,
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.models.QuarantineRef
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||
@@ -43,9 +44,10 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
|
||||
"and it'll show up here. The track stays out of system " +
|
||||
"playlists until you unhide it.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load hidden tracks",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is UiState.Success -> HiddenList(
|
||||
rows = s.data,
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.quarantine.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.QuarantineRef
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
@@ -24,6 +26,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
class HiddenTabViewModel @Inject constructor(
|
||||
private val repository: QuarantineRepository,
|
||||
private val eventsStream: EventsStream,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
@@ -61,6 +64,11 @@ class HiddenTabViewModel @Inject constructor(
|
||||
.filter { it.kind.startsWith("quarantine.") }
|
||||
.collect { refresh() }
|
||||
}
|
||||
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||
// health returns, so a load that failed offline heals unprompted.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
@@ -78,9 +79,10 @@ fun RequestsScreen(
|
||||
body = "Use Discover to ask Lidarr for new music; your " +
|
||||
"requests show up here.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load requests",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is UiState.Success -> RequestList(
|
||||
rows = s.data,
|
||||
|
||||
+16
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.requests.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.models.RequestStatus
|
||||
@@ -30,6 +32,7 @@ private const val POLL_INTERVAL_MS = 12_000L
|
||||
class RequestsViewModel @Inject constructor(
|
||||
private val repository: RequestsRepository,
|
||||
private val eventsStream: EventsStream,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
||||
@@ -45,6 +48,14 @@ class RequestsViewModel @Inject constructor(
|
||||
.collect { silentReload() }
|
||||
}
|
||||
viewModelScope.launch { pollWhileInFlight() }
|
||||
// Screen-level auto-recovery (issue #1245): a load that failed while
|
||||
// the server was unreachable heals the moment health returns, without
|
||||
// waiting for the next poll tick.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is UiState.Error) silentReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
@@ -71,7 +82,11 @@ class RequestsViewModel @Inject constructor(
|
||||
*/
|
||||
private suspend fun pollWhileInFlight() {
|
||||
while (true) {
|
||||
if (hasInFlightRequest()) {
|
||||
// Also retry from a failed load (issue #1245): from UiState.Error
|
||||
// the in-flight predicate can never become true, so without this
|
||||
// the poll never self-heals the screen — e.g. after a transient
|
||||
// 500 that health monitoring (transport-level) never saw.
|
||||
if (hasInFlightRequest() || internal.value is UiState.Error) {
|
||||
silentReload()
|
||||
}
|
||||
delay(POLL_INTERVAL_MS)
|
||||
|
||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||
@@ -113,6 +114,7 @@ fun SearchScreen(
|
||||
onTrackPlay = viewModel::playTrack,
|
||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
onRetry = viewModel::retry,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -151,11 +153,16 @@ private fun ResultsPane(
|
||||
onTrackPlay: (TrackRef) -> Unit,
|
||||
onNavigateToAlbum: (String) -> Unit,
|
||||
onNavigateToArtist: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||
SearchResultsState.Loading -> LoadingCentered()
|
||||
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
||||
is SearchResultsState.Error -> ErrorRetry(
|
||||
title = "Search failed",
|
||||
message = state.message,
|
||||
onRetry = onRetry,
|
||||
)
|
||||
is SearchResultsState.Loaded -> {
|
||||
if (state.response.isEmpty) {
|
||||
CenteredHint(
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.search.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
@@ -43,6 +45,7 @@ data class SearchState(
|
||||
class SearchViewModel @Inject constructor(
|
||||
private val repository: SearchRepository,
|
||||
private val player: PlayerController,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val queryFlow = MutableStateFlow("")
|
||||
@@ -69,6 +72,14 @@ class SearchViewModel @Inject constructor(
|
||||
runSearch(q)
|
||||
}
|
||||
}
|
||||
// Screen-level auto-recovery (issue #1245): a search that failed
|
||||
// while the server was unreachable re-runs when health returns —
|
||||
// the queryFlow pipeline can't, since the text hasn't changed.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value.results is SearchResultsState.Error) retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setQuery(value: String) {
|
||||
@@ -80,6 +91,18 @@ class SearchViewModel @Inject constructor(
|
||||
setQuery("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the current query after a failure. Bypasses the queryFlow
|
||||
* pipeline, whose distinctUntilChanged drops a same-text resubmit —
|
||||
* without this, a transient error pins "Search failed" until the
|
||||
* user edits the query.
|
||||
*/
|
||||
fun retry() {
|
||||
val q = internal.value.query.trim()
|
||||
if (q.isEmpty()) return
|
||||
viewModelScope.launch { runSearch(q) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tapping a search result builds a queue from the full visible
|
||||
* track-results list starting at the tapped entry, matching Flutter.
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -94,6 +95,7 @@ fun SettingsScreen(
|
||||
onPickTheme = themeVm::setThemeMode,
|
||||
onNavToRequests = { navController.navigate(Requests) },
|
||||
onNavToAdmin = { navController.navigate(Admin) },
|
||||
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
||||
onSignOutClick = { showSignOutConfirm = true },
|
||||
)
|
||||
}
|
||||
@@ -116,6 +118,7 @@ private fun SettingsList(
|
||||
onPickTheme: (ThemeMode) -> Unit,
|
||||
onNavToRequests: () -> Unit,
|
||||
onNavToAdmin: () -> Unit,
|
||||
onToggleDiagnostics: (Boolean) -> Unit,
|
||||
onSignOutClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
@@ -148,6 +151,12 @@ private fun SettingsList(
|
||||
ProfileCard()
|
||||
PasswordCard()
|
||||
ListenBrainzCard()
|
||||
if (state.diagnosticsEnabledByAdmin) {
|
||||
DiagnosticsCard(
|
||||
optOut = state.diagnosticsOptOut,
|
||||
onToggle = onToggleDiagnostics,
|
||||
)
|
||||
}
|
||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||
StorageCard()
|
||||
AboutCard()
|
||||
@@ -262,6 +271,46 @@ private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) {
|
||||
}
|
||||
}
|
||||
|
||||
// Shown only while an admin has enabled debug mode on this account. Lets
|
||||
// the user turn reporting OFF on this device (battery/privacy) without
|
||||
// touching the account flag. Checked = reporting on = NOT opted out.
|
||||
@Composable
|
||||
private fun DiagnosticsCard(optOut: Boolean, onToggle: (Boolean) -> Unit) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Diagnostics",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Send diagnostic reports",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Your admin enabled debug mode to investigate an " +
|
||||
"issue. This device streams connectivity and playback " +
|
||||
"diagnostics. Turn off any time.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Switch(
|
||||
checked = !optOut,
|
||||
onCheckedChange = { checked -> onToggle(!checked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
||||
|
||||
+13
-2
@@ -19,12 +19,15 @@ data class SettingsState(
|
||||
val isAdmin: Boolean = false,
|
||||
val isSigningOut: Boolean = false,
|
||||
val signedOut: Boolean = false,
|
||||
// M9: account debug flag (admin-set) + this device's local opt-out.
|
||||
val diagnosticsEnabledByAdmin: Boolean = false,
|
||||
val diagnosticsOptOut: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val authController: AuthController,
|
||||
authStore: AuthStore,
|
||||
private val authStore: AuthStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val transient = MutableStateFlow(TransientState())
|
||||
@@ -41,19 +44,27 @@ class SettingsViewModel @Inject constructor(
|
||||
combine(
|
||||
authStore.baseUrl,
|
||||
authController.currentUser,
|
||||
authStore.diagnosticsOptOut,
|
||||
transient,
|
||||
) { url, user, t ->
|
||||
) { url, user, optOut, t ->
|
||||
SettingsState(
|
||||
serverUrl = url,
|
||||
username = user?.username.orEmpty(),
|
||||
isAdmin = user?.isAdmin == true,
|
||||
isSigningOut = t.isSigningOut,
|
||||
signedOut = t.signedOut,
|
||||
diagnosticsEnabledByAdmin = user?.debugModeEnabled == true,
|
||||
diagnosticsOptOut = optOut,
|
||||
)
|
||||
}.collect { internal.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-device opt-out of diagnostics while the account flag is on. */
|
||||
fun setDiagnosticsOptOut(optOut: Boolean) {
|
||||
authStore.setDiagnosticsOptOut(optOut)
|
||||
}
|
||||
|
||||
fun signOut() {
|
||||
if (transient.value.isSigningOut) return
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package com.fabledsword.minstrel.shared
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
// Once raised, the veil stays up at least this long. Without a floor a
|
||||
// no-op refresh wipes on and straight back off, which reads as a glitch.
|
||||
private const val DEFAULT_MIN_HOLD_MS = 900L
|
||||
|
||||
// The screen must stop changing for this long before the veil lowers.
|
||||
// Every content change re-arms it, so a refresh that lands in stages
|
||||
// (index → tile hydration → artwork) holds the veil across all of them.
|
||||
private const val DEFAULT_QUIET_MS = 700L
|
||||
|
||||
// Hard ceiling on visibility. A refresh that never settles must not
|
||||
// strand the user behind an opaque veil; the work itself is NOT capped.
|
||||
private const val DEFAULT_MAX_HOLD_MS = 12_000L
|
||||
|
||||
// Attempts per session. Retrying behind the veil is the point: a pull
|
||||
// that fails on the first try gets another go before the user sees
|
||||
// anything, instead of the veil wiping off over unchanged content.
|
||||
private const val DEFAULT_ATTEMPTS = 3
|
||||
private const val DEFAULT_RETRY_BACKOFF_MS = 600L
|
||||
|
||||
/** Tunables for [UpdateVeilController]; defaults are the Home values. */
|
||||
data class VeilTimings(
|
||||
val minHoldMs: Long = DEFAULT_MIN_HOLD_MS,
|
||||
val quietMs: Long = DEFAULT_QUIET_MS,
|
||||
val maxHoldMs: Long = DEFAULT_MAX_HOLD_MS,
|
||||
val attempts: Int = DEFAULT_ATTEMPTS,
|
||||
val retryBackoffMs: Long = DEFAULT_RETRY_BACKOFF_MS,
|
||||
)
|
||||
|
||||
/**
|
||||
* A snapshot of everything that visibly moves on the veiled screen.
|
||||
*
|
||||
* @param contentKey any value whose equality tracks what's rendered — a
|
||||
* change means the screen moved, and re-arms the quiet timer.
|
||||
* @param hasContent true when real content (not a skeleton or an empty
|
||||
* state) is on screen. The veil waits for this before raising: there's
|
||||
* nothing to hide until there's something to hide.
|
||||
* @param quiescent false while something is still landing (artwork
|
||||
* loading, tiles hydrating). The veil will not lower until this is
|
||||
* true, up to [VeilTimings.maxHoldMs].
|
||||
*/
|
||||
data class VeilSettleState(
|
||||
val contentKey: Any?,
|
||||
val hasContent: Boolean,
|
||||
val quiescent: Boolean,
|
||||
)
|
||||
|
||||
/** What a finished session did, so callers can report it if they want. */
|
||||
enum class VeilOutcome {
|
||||
/** Content changed, and the veil covered the churn. */
|
||||
CHANGED,
|
||||
|
||||
/** The refresh worked, but nothing on screen moved — already current. */
|
||||
UNCHANGED,
|
||||
|
||||
/** Every attempt failed. */
|
||||
FAILED,
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's result, plus whether a user explicitly asked for it.
|
||||
*
|
||||
* [userInitiated] is what lets a caller tell feedback from noise: a user
|
||||
* who pulled to refresh is owed an answer even when the answer is "nothing
|
||||
* changed", while the same outcome from a background check is noise.
|
||||
*/
|
||||
data class VeilSessionResult(
|
||||
val outcome: VeilOutcome,
|
||||
val userInitiated: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Drives an "updating" overlay from *observed content change and settling*
|
||||
* rather than from a fixed delay.
|
||||
*
|
||||
* The problem this replaces: a veil held for `refresh().join() + 500ms`
|
||||
* lowers while the screen is still moving, because finishing the network
|
||||
* pull is nowhere near the end of the visible work — the pull writes id
|
||||
* lists, then tiles hydrate one by one, then artwork loads. And a plain
|
||||
* `isUpdating` Boolean set in a `finally` gets cleared by whichever of
|
||||
* two overlapping refreshes finishes first, wiping the veil off mid-update
|
||||
* (issue #2327).
|
||||
*
|
||||
* So instead: run [work], raise only if the content actually changes, then
|
||||
* hold until [settleSignal] reports the screen has stopped changing for
|
||||
* [VeilTimings.quietMs] AND is quiescent — bounded below by
|
||||
* [VeilTimings.minHoldMs] so it can never flash, and above by
|
||||
* [VeilTimings.maxHoldMs] so it can never strand.
|
||||
*
|
||||
* The raise is deliberately *reactive*: a refresh that returns what's
|
||||
* already on screen — the common case on a launch over a warm cache —
|
||||
* raises nothing at all, because a veil over an unchanged screen hides
|
||||
* nothing and only delays first paint. The cost is that the veil arrives
|
||||
* one emission after the change, so a single atomic content swap shows
|
||||
* through; everything messier that follows it (tile hydration, then
|
||||
* artwork) still lands behind the veil.
|
||||
*
|
||||
* Overlapping triggers extend the running session instead of racing it,
|
||||
* so the veil stays up continuously rather than lowering and re-raising.
|
||||
*
|
||||
* Failure is quiet at this layer: [work] gets [VeilTimings.attempts] tries
|
||||
* behind the veil, and if they all fail the veil simply wipes off over the
|
||||
* cached content. Giving up ends only *this* session — it sets no latch and
|
||||
* blocks nothing, so the caller's own recovery paths (reconnect re-pull,
|
||||
* freshness sweeps, the next event, a manual pull) keep retrying afterwards
|
||||
* exactly as before. Callers that want to surface a failure can do it from
|
||||
* [onSessionEnd] instead.
|
||||
*
|
||||
* @param work one refresh attempt; returns true when it succeeded.
|
||||
* @param shouldVeil sampled at session start — "is there cached content
|
||||
* this refresh is about to overwrite?". False means a cold load, where
|
||||
* a skeleton is the right affordance, and the work runs unveiled.
|
||||
* @param onSessionEnd called once per finished session, on the controller's
|
||||
* coroutine. Use it for user-facing feedback the veil itself can't give.
|
||||
*/
|
||||
class UpdateVeilController(
|
||||
private val scope: CoroutineScope,
|
||||
private val settleSignal: Flow<VeilSettleState>,
|
||||
private val shouldVeil: suspend () -> Boolean,
|
||||
private val timings: VeilTimings = VeilTimings(),
|
||||
private val onSessionEnd: (VeilSessionResult) -> Unit = {},
|
||||
private val work: suspend () -> Boolean,
|
||||
) {
|
||||
private val visibleInternal = MutableStateFlow(false)
|
||||
|
||||
/** True while the veil should be drawn over the screen. */
|
||||
val visible: StateFlow<Boolean> = visibleInternal.asStateFlow()
|
||||
|
||||
private val finishedInternal = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Increments as each session ends. Lets a caller wait for "this
|
||||
* refresh is done" without knowing whether a veil ever went up —
|
||||
* a pull-to-refresh indicator needs exactly that, since an unchanged
|
||||
* refresh never raises one.
|
||||
*/
|
||||
val finishedSessions: StateFlow<Int> = finishedInternal.asStateFlow()
|
||||
|
||||
// Conflated: a burst of triggers (reconnect + rebuild event arriving
|
||||
// together) collapses into one follow-up pass, not a queue of them.
|
||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
// Sticky across a conflated burst: conflation drops the older token, so
|
||||
// the "a user asked for this" bit can't ride on it. If ANY coalesced
|
||||
// trigger was the user's, the session still owes them an answer.
|
||||
private val userAsked = AtomicBoolean(false)
|
||||
|
||||
init {
|
||||
// One consumer, so sessions are serialised by construction: two
|
||||
// triggers can never each own a piece of the veil's state.
|
||||
scope.launch {
|
||||
while (true) {
|
||||
requests.receive()
|
||||
runSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a refresh. Safe to call from any trigger at any rate —
|
||||
* calls arriving during a session extend it rather than starting a
|
||||
* competing one.
|
||||
*
|
||||
* @param userInitiated true when a person explicitly asked (pull to
|
||||
* refresh, a Retry button), which is what [VeilSessionResult] carries
|
||||
* through to [onSessionEnd].
|
||||
*/
|
||||
fun request(userInitiated: Boolean = false) {
|
||||
if (userInitiated) userAsked.set(true)
|
||||
requests.trySend(Unit)
|
||||
}
|
||||
|
||||
private suspend fun runSession() {
|
||||
// Sampled at both ends of the work: a trigger folded in mid-session
|
||||
// (see [drainWork]) may have been the user's, and they're still owed
|
||||
// an answer for it.
|
||||
val askedAtStart = userAsked.getAndSet(false)
|
||||
if (!shouldVeil()) {
|
||||
// Cold load: the skeleton is the right affordance, so no veil.
|
||||
// Succeeding here did change the screen — from nothing to
|
||||
// something — so it reports CHANGED, never "already up to date".
|
||||
val ok = drainWork()
|
||||
finish(succeeded = ok, changed = ok, userInitiated = askedAtStart)
|
||||
return
|
||||
}
|
||||
val raised = CompletableDeferred<Unit>()
|
||||
val raiser = scope.launch { raiseWhenContentChanges(raised) }
|
||||
// Floor and ceiling are measured from the raise, not the request,
|
||||
// so a late raise still gets its full no-flash minimum.
|
||||
val floor = scope.launch {
|
||||
raised.await()
|
||||
delay(timings.minHoldMs)
|
||||
}
|
||||
val ceiling = scope.launch {
|
||||
raised.await()
|
||||
delay(timings.maxHoldMs)
|
||||
visibleInternal.value = false
|
||||
}
|
||||
var succeeded = false
|
||||
try {
|
||||
succeeded = drainWork()
|
||||
// Always wait for the settle, never conditionally on `visible`:
|
||||
// work that finishes without suspending would otherwise reach
|
||||
// here before the raiser has been dispatched, tear the session
|
||||
// down, and leave the churn uncovered. This wait is also what
|
||||
// makes `raised.isCompleted` below a trustworthy "did anything
|
||||
// change?" — a change landing just after the pull returns still
|
||||
// gets seen.
|
||||
withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() }
|
||||
// Honour the no-flash minimum before lowering. Deliberately in
|
||||
// the try and not the finally: on cancellation the scope is
|
||||
// going away and nothing will render the veil, so the floor is
|
||||
// pointless there — and a finally that suspends is a finally
|
||||
// that can resist teardown.
|
||||
if (raised.isCompleted) floor.join()
|
||||
} finally {
|
||||
raiser.cancel()
|
||||
ceiling.cancel()
|
||||
floor.cancel()
|
||||
visibleInternal.value = false
|
||||
finish(
|
||||
succeeded = succeeded,
|
||||
changed = raised.isCompleted,
|
||||
userInitiated = askedAtStart,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the veil the moment the screen's content differs from what was
|
||||
* already on it — and never, if this refresh turns out to be a no-op.
|
||||
*
|
||||
* The baseline is the first state that HAS content, not simply the first
|
||||
* state: over a warm cache the cached rows paint a moment after the
|
||||
* session starts, and treating that first paint as "a change" would veil
|
||||
* every launch, which is the whole thing this avoids.
|
||||
*/
|
||||
private suspend fun raiseWhenContentChanges(raised: CompletableDeferred<Unit>) {
|
||||
val baseline = settleSignal.first { it.hasContent }
|
||||
settleSignal.first { it.hasContent && it.contentKey != baseline.contentKey }
|
||||
visibleInternal.value = true
|
||||
raised.complete(Unit)
|
||||
}
|
||||
|
||||
private fun finish(succeeded: Boolean, changed: Boolean, userInitiated: Boolean) {
|
||||
val outcome = when {
|
||||
!succeeded -> VeilOutcome.FAILED
|
||||
changed -> VeilOutcome.CHANGED
|
||||
else -> VeilOutcome.UNCHANGED
|
||||
}
|
||||
onSessionEnd(
|
||||
VeilSessionResult(
|
||||
outcome = outcome,
|
||||
// Fold in a mid-session request from the user.
|
||||
userInitiated = userInitiated || userAsked.getAndSet(false),
|
||||
),
|
||||
)
|
||||
finishedInternal.update { it + 1 }
|
||||
}
|
||||
|
||||
/** True when the refresh eventually succeeded. */
|
||||
private suspend fun drainWork(): Boolean {
|
||||
var succeeded: Boolean
|
||||
do {
|
||||
succeeded = runWorkWithRetries()
|
||||
// A trigger that arrived mid-session gets folded into this one.
|
||||
} while (requests.tryReceive().isSuccess)
|
||||
return succeeded
|
||||
}
|
||||
|
||||
private suspend fun runWorkWithRetries(): Boolean {
|
||||
repeat(timings.attempts) { attempt ->
|
||||
if (work()) return true
|
||||
if (attempt < timings.attempts - 1) {
|
||||
delay(timings.retryBackoffMs * (attempt + 1))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until the screen has been unchanged for
|
||||
* [VeilTimings.quietMs] and reports itself quiescent.
|
||||
*
|
||||
* `debounce` is what makes this hold across a staged update: every
|
||||
* change restarts the window, so the veil lowers only once emissions
|
||||
* actually stop. `first { quiescent }` then rejects a quiet-but-
|
||||
* still-loading moment and waits for the next lull.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
private suspend fun awaitSettled() {
|
||||
settleSignal
|
||||
.distinctUntilChanged()
|
||||
.debounce(timings.quietMs)
|
||||
.first { it.quiescent }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Counts the cover-art loads that are currently in flight, so a
|
||||
* screen-level overlay can wait for the artwork to actually land instead
|
||||
* of guessing with a fixed delay.
|
||||
*
|
||||
* Artwork is the most visible pop-in on Home: a tile can be fully
|
||||
* hydrated (title, artist, counts all present) and still snap its cover
|
||||
* in a second later, which is exactly the churn the "Updating your
|
||||
* mixes…" veil exists to hide. The refresh coroutine can't see that —
|
||||
* it finishes long before Coil does — so the composition reports it
|
||||
* upward here instead.
|
||||
*
|
||||
* [ServerImage] reports into whatever tracker it finds in
|
||||
* [LocalArtSettleTracker], which means every art surface in the app
|
||||
* participates for free. Only *composed* images are counted, so a
|
||||
* LazyRow's off-screen tiles are correctly ignored — the count tracks
|
||||
* the pop-in a user can actually see.
|
||||
*
|
||||
* Provide one per screen that needs it (typically owned by the
|
||||
* screen's ViewModel so its refresh logic can read [inFlight]):
|
||||
*
|
||||
* CompositionLocalProvider(LocalArtSettleTracker provides vm.artTracker) { ... }
|
||||
*/
|
||||
@Stable
|
||||
class ArtSettleTracker {
|
||||
private val inFlightInternal = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* How many on-screen images are still loading. Zero means the
|
||||
* artwork has settled — every composed cover has either drawn or
|
||||
* failed to a fallback.
|
||||
*/
|
||||
val inFlight: StateFlow<Int> = inFlightInternal.asStateFlow()
|
||||
|
||||
fun begin() {
|
||||
inFlightInternal.update { it + 1 }
|
||||
}
|
||||
|
||||
fun end() {
|
||||
// Floor at zero: a decrement can outlive its increment when a
|
||||
// tile is disposed mid-load and the count must not go negative
|
||||
// and wedge "settled" off forever.
|
||||
inFlightInternal.update { (it - 1).coerceAtLeast(0) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tracker [ServerImage] reports load state to, or null on screens
|
||||
* that don't care (the default) — reporting is then a no-op.
|
||||
*/
|
||||
val LocalArtSettleTracker = staticCompositionLocalOf<ArtSettleTracker?> { null }
|
||||
@@ -23,8 +23,12 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
* ServerImage + fallback structure was identical at all three sites.
|
||||
*
|
||||
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
||||
* (e.g. the `VariantPill` system-playlist label) — callers can use
|
||||
* `Modifier.align(...)` inside it.
|
||||
* (e.g. the `VariantPill` system-playlist label, the play button) —
|
||||
* callers can use `Modifier.align(...)` inside it. The overlay sits on
|
||||
* an outer, UN-clipped box so corner-anchored widgets are not cut off by
|
||||
* [shape]: a `BottomEnd` play button on a `CircleShape` avatar falls in
|
||||
* the square's corner, outside the circle, and would otherwise be clipped
|
||||
* away. Only the artwork + background are clipped to [shape].
|
||||
*/
|
||||
@Composable
|
||||
fun CoverTile(
|
||||
@@ -38,8 +42,12 @@ fun CoverTile(
|
||||
overlay: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
modifier = modifier.size(size),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(shape)
|
||||
.background(background),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -51,6 +59,7 @@ fun CoverTile(
|
||||
) {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
overlay()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -23,18 +24,26 @@ import com.fabledsword.minstrel.theme.LocalActionColors
|
||||
* Shared error-with-retry widget. Surfaces a brief message and a
|
||||
* primary action button. Per the design system, the button uses
|
||||
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
||||
*
|
||||
* Renders inside a single-item LazyColumn (same rationale as
|
||||
* [EmptyState]) so the widget participates in nested-scroll dispatch
|
||||
* under PullToRefreshBox — both the Retry button and a pull gesture
|
||||
* can recover from the error state.
|
||||
*/
|
||||
@Composable
|
||||
fun ErrorRetry(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
retryLabel: String = "Retry",
|
||||
) {
|
||||
val actionColors = LocalActionColors.current
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
modifier = Modifier
|
||||
.fillParentMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -45,9 +54,18 @@ fun ErrorRetry(
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -63,4 +81,6 @@ fun ErrorRetry(
|
||||
Text(retryLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
|
||||
// The fallback fades out as the artwork crossfades in (Coil's crossfade is
|
||||
// configured globally on the ImageLoader in MinstrelApplication). Matching
|
||||
// durations makes the swap read as one cross-dissolve; without the fade the
|
||||
// placeholder icon vanished a frame before the cover appeared, which is the
|
||||
// "art popping in" the Home veil exists to hide (issue #2327).
|
||||
private const val FALLBACK_FADE_MS = 220
|
||||
|
||||
/**
|
||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||
* is blank or unresolvable.
|
||||
* is blank/unresolvable, while the image is still loading, and when the
|
||||
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||
* which the "You might like" row hits often).
|
||||
*
|
||||
* In-flight loads are reported to [LocalArtSettleTracker] when a screen
|
||||
* provides one, so a screen-level overlay can wait for artwork to land
|
||||
* instead of guessing with a fixed delay.
|
||||
*/
|
||||
@Composable
|
||||
fun ServerImage(
|
||||
@@ -22,12 +47,43 @@ fun ServerImage(
|
||||
val resolved = resolveServerUrl(url)
|
||||
if (resolved == null) {
|
||||
fallback()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
// Track Coil's load state so the fallback doubles as a placeholder
|
||||
// (loading) and an error state (404 / unreachable) — not just a
|
||||
// null-URL guard, which left present-but-failing URLs blank.
|
||||
var state by remember(resolved) {
|
||||
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
||||
}
|
||||
// Empty counts as loading: it's the pre-request state, so treating it
|
||||
// as settled would let a screen overlay lower before Coil even starts.
|
||||
val loading = state is AsyncImagePainter.State.Empty ||
|
||||
state is AsyncImagePainter.State.Loading
|
||||
val tracker = LocalArtSettleTracker.current
|
||||
DisposableEffect(tracker, loading) {
|
||||
if (loading) tracker?.begin()
|
||||
// Balanced by construction: the effect re-runs when `loading` flips
|
||||
// (decrement, then no re-increment) and disposes when a tile leaves
|
||||
// the composition mid-load (scrolled away).
|
||||
onDispose { if (loading) tracker?.end() }
|
||||
}
|
||||
val fallbackAlpha by animateFloatAsState(
|
||||
targetValue = if (loading || state is AsyncImagePainter.State.Error) 1f else 0f,
|
||||
animationSpec = tween(FALLBACK_FADE_MS),
|
||||
label = "art-fallback",
|
||||
)
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = resolved,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = contentScale,
|
||||
onState = { state = it },
|
||||
)
|
||||
if (fallbackAlpha > 0f) {
|
||||
Box(Modifier.alpha(fallbackAlpha), contentAlignment = Alignment.Center) {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +56,51 @@ class BuildPlaylistsRowTest {
|
||||
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
||||
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
|
||||
private fun songsLike(id: String, cached: Boolean) = PlaylistRef(
|
||||
id = id,
|
||||
userId = "u",
|
||||
name = "Songs like $id",
|
||||
systemVariant = "songs_like_artist",
|
||||
trackCount = 25,
|
||||
fullyCached = cached,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `songs-like no longer appears in the main playlists carousel`() {
|
||||
val owned = listOf(songsLike("a", cached = true), user("u1", cached = true))
|
||||
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertTrue("a" !in reals, "songs-like mix leaked into the Playlists row: $reals")
|
||||
assertTrue("u1" in reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online songs-like row shows every generated mix uncapped`() {
|
||||
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
|
||||
val owned = (1..6).map { songsLike("a$it", cached = true) }
|
||||
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertEquals((1..6).map { "a$it" }, reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online songs-like row shows placeholders when none generated`() {
|
||||
val row = buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = false)
|
||||
assertTrue(row.isNotEmpty())
|
||||
assertTrue(row.all { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline songs-like row shows cached mixes available-first, none hides it`() {
|
||||
val owned = listOf(songsLike("partial", cached = false), songsLike("full", cached = true))
|
||||
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = true)
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
// Fully-cached songs-like mix (not refreshable) is available; the
|
||||
// un-cached one greys and sorts after. No placeholders offline.
|
||||
assertEquals(listOf("full", "partial"), reals)
|
||||
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
|
||||
|
||||
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
+12
-4
@@ -11,6 +11,7 @@ import com.fabledsword.minstrel.testutil.MainDispatcherExtension
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -21,6 +22,13 @@ import kotlin.test.assertEquals
|
||||
@ExtendWith(MainDispatcherExtension::class)
|
||||
class LibraryViewModelTest {
|
||||
|
||||
// lastSyncError needs a real StateFlow: the VM combines it into uiState,
|
||||
// and a relaxed-mock flow never emits, so the combine would never fire.
|
||||
private fun syncControllerMock(): SyncController =
|
||||
mockk<SyncController>(relaxed = true) {
|
||||
every { lastSyncError } returns MutableStateFlow(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is Loading before any DAO emission`() = runTest {
|
||||
val repo = mockk<LibraryRepository>()
|
||||
@@ -29,7 +37,7 @@ class LibraryViewModelTest {
|
||||
|
||||
val vm = LibraryViewModel(
|
||||
repo,
|
||||
mockk<SyncController>(relaxed = true),
|
||||
syncControllerMock(),
|
||||
mockk<PlayerController>(relaxed = true),
|
||||
)
|
||||
|
||||
@@ -51,7 +59,7 @@ class LibraryViewModelTest {
|
||||
|
||||
val vm = LibraryViewModel(
|
||||
repo,
|
||||
mockk<SyncController>(relaxed = true),
|
||||
syncControllerMock(),
|
||||
mockk<PlayerController>(relaxed = true),
|
||||
)
|
||||
|
||||
@@ -71,7 +79,7 @@ class LibraryViewModelTest {
|
||||
|
||||
val vm = LibraryViewModel(
|
||||
repo,
|
||||
mockk<SyncController>(relaxed = true),
|
||||
syncControllerMock(),
|
||||
mockk<PlayerController>(relaxed = true),
|
||||
)
|
||||
|
||||
@@ -94,7 +102,7 @@ class LibraryViewModelTest {
|
||||
|
||||
val vm = LibraryViewModel(
|
||||
repo,
|
||||
mockk<SyncController>(relaxed = true),
|
||||
syncControllerMock(),
|
||||
mockk<PlayerController>(relaxed = true),
|
||||
)
|
||||
|
||||
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
package com.fabledsword.minstrel.shared
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
private const val WORK_MS = 1_000L
|
||||
private const val CHURN_ROUNDS = 5
|
||||
private const val ART_IN_FLIGHT = 3
|
||||
private const val SUCCEED_ON_ATTEMPT = 3
|
||||
private const val QUIET_WINDOWS_TO_OUTLAST = 3
|
||||
|
||||
// Time to let a session finish once the screen has stopped changing: the
|
||||
// retry backoffs, the quiet window and the minimum hold all fit inside it,
|
||||
// while staying well under maxHoldMs. That gap matters — if a drain ran
|
||||
// past the ceiling, "the veil lowered" would no longer distinguish
|
||||
// "it settled" from "it gave up", which is the whole point of these tests.
|
||||
private const val DRAIN_MS = 3_000L
|
||||
|
||||
/**
|
||||
* The veil's job is to go up only when content actually changes, and then to
|
||||
* stay up until the screen has stopped moving. Each test pins one of the ways
|
||||
* the original fixed-delay implementation got that wrong (issue #2327).
|
||||
*
|
||||
* The controller is built on `backgroundScope` throughout: its consumer
|
||||
* loop runs forever, so hanging it off the test's own scope would stop
|
||||
* `runTest` from ever completing.
|
||||
*
|
||||
* Consequence, and the reason every wait below is an explicit
|
||||
* `advanceTimeBy`: **`advanceUntilIdle()` is useless here.** It advances
|
||||
* only while *foreground* work remains, and everything this controller
|
||||
* does lives in `backgroundScope` — so it returns having run nothing, and
|
||||
* assertions land on a session that never started (CI run 3163 failed all
|
||||
* seven of these with "expected 3, actual 0" and friends). Drive the clock
|
||||
* deliberately instead; don't "simplify" these back to advanceUntilIdle.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class UpdateVeilControllerTest {
|
||||
|
||||
private val timings = VeilTimings(
|
||||
minHoldMs = 900,
|
||||
quietMs = 700,
|
||||
maxHoldMs = 12_000,
|
||||
attempts = 3,
|
||||
retryBackoffMs = 600,
|
||||
)
|
||||
|
||||
/** Drives the settle signal by hand: content key, presence, art count. */
|
||||
private class FakeScreen(hasContent: Boolean = true) {
|
||||
val state = MutableStateFlow(Triple(0, hasContent, 0))
|
||||
|
||||
val signal = state.map { (key, hasContent, art) ->
|
||||
VeilSettleState(contentKey = key, hasContent = hasContent, quiescent = art == 0)
|
||||
}
|
||||
|
||||
/** Content visibly changed — what the veil exists to cover. */
|
||||
fun churn() {
|
||||
state.value = state.value.copy(first = state.value.first + 1)
|
||||
}
|
||||
|
||||
fun artLoading(count: Int) {
|
||||
state.value = state.value.copy(third = count)
|
||||
}
|
||||
|
||||
fun contentAppears() {
|
||||
state.value = state.value.copy(second = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.controllerOn(
|
||||
screen: FakeScreen,
|
||||
shouldVeil: suspend () -> Boolean = { true },
|
||||
onSessionEnd: (VeilSessionResult) -> Unit = {},
|
||||
work: suspend () -> Boolean,
|
||||
) = UpdateVeilController(
|
||||
scope = backgroundScope,
|
||||
settleSignal = screen.signal,
|
||||
shouldVeil = shouldVeil,
|
||||
timings = timings,
|
||||
onSessionEnd = onSessionEnd,
|
||||
work = work,
|
||||
)
|
||||
|
||||
/** Records every visibility transition, so an extra raise can't hide. */
|
||||
private fun TestScope.recordVisibility(controller: UpdateVeilController): List<Boolean> {
|
||||
val seen = mutableListOf<Boolean>()
|
||||
backgroundScope.launch { controller.visible.collect { seen.add(it) } }
|
||||
return seen
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `veil outlasts content that keeps churning after the pull returns`() = runTest {
|
||||
val screen = FakeScreen()
|
||||
val controller = controllerOn(screen) { true }
|
||||
|
||||
controller.request()
|
||||
runCurrent()
|
||||
// The pull's write lands: content changed, so the veil goes up.
|
||||
screen.churn()
|
||||
runCurrent()
|
||||
assertTrue(controller.visible.value, "veil is up while the screen is still moving")
|
||||
|
||||
// Tiles hydrating one after another, each inside the quiet window.
|
||||
// The old implementation had already wiped off after a flat 500ms.
|
||||
repeat(CHURN_ROUNDS) {
|
||||
advanceTimeBy(timings.quietMs / 2)
|
||||
screen.churn()
|
||||
runCurrent()
|
||||
assertTrue(controller.visible.value, "veil must hold across staged churn")
|
||||
}
|
||||
|
||||
advanceTimeBy(DRAIN_MS)
|
||||
assertFalse(controller.visible.value, "veil lowers once the screen goes quiet")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `veil waits for artwork to finish loading`() = runTest {
|
||||
val screen = FakeScreen()
|
||||
val controller = controllerOn(screen) { true }
|
||||
|
||||
screen.artLoading(ART_IN_FLIGHT)
|
||||
controller.request()
|
||||
runCurrent()
|
||||
screen.churn()
|
||||
runCurrent()
|
||||
// Well past the quiet window and the floor — but art is still in
|
||||
// flight, so lowering now would show the covers popping in.
|
||||
advanceTimeBy(timings.minHoldMs + timings.quietMs * QUIET_WINDOWS_TO_OUTLAST)
|
||||
assertTrue(controller.visible.value, "veil must wait on in-flight art")
|
||||
|
||||
screen.artLoading(0)
|
||||
advanceTimeBy(DRAIN_MS)
|
||||
assertFalse(controller.visible.value, "veil lowers once art has landed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retries quietly, then veils the churn the successful attempt produces`() = runTest {
|
||||
val screen = FakeScreen()
|
||||
var attempts = 0
|
||||
val controller = controllerOn(screen) {
|
||||
attempts++
|
||||
val succeeded = attempts >= SUCCEED_ON_ATTEMPT // fail twice
|
||||
// Only a pull that worked writes anything.
|
||||
if (succeeded) screen.churn()
|
||||
succeeded
|
||||
}
|
||||
val seen = recordVisibility(controller)
|
||||
|
||||
controller.request()
|
||||
runCurrent()
|
||||
// A failed pull changes nothing, so there is nothing to hide yet —
|
||||
// the retries happen with no veil at all.
|
||||
assertFalse(controller.visible.value, "no veil over a pull that changed nothing")
|
||||
|
||||
advanceTimeBy(DRAIN_MS)
|
||||
assertEquals(SUCCEED_ON_ATTEMPT, attempts, "retries until the pull succeeds")
|
||||
assertEquals(
|
||||
listOf(false, true, false),
|
||||
seen,
|
||||
"the veil went up once, over the churn the retry finally produced",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `giving up is silent, reports FAILED, and does not block later requests`() = runTest {
|
||||
val screen = FakeScreen()
|
||||
val results = mutableListOf<VeilSessionResult>()
|
||||
var attempts = 0
|
||||
var succeed = false
|
||||
val controller = controllerOn(screen, onSessionEnd = { results += it }) {
|
||||
attempts++
|
||||
succeed
|
||||
}
|
||||
|
||||
controller.request(userInitiated = true)
|
||||
advanceTimeBy(DRAIN_MS)
|
||||
assertEquals(timings.attempts, attempts, "exhausts its attempts")
|
||||
assertFalse(controller.visible.value, "no veil — a failed pull changed nothing")
|
||||
assertEquals(VeilOutcome.FAILED, results.single().outcome)
|
||||
assertTrue(results.single().userInitiated, "the user asked, so they're owed an answer")
|
||||
|
||||
// Giving up must not latch anything off — the reconnect-driven
|
||||
// recovery still gets to try again later.
|
||||
succeed = true
|
||||
controller.request()
|
||||
advanceTimeBy(DRAIN_MS)
|
||||
assertEquals(timings.attempts + 1, attempts, "a later request still runs")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlapping requests extend one veil instead of racing it`() = runTest {
|
||||
val screen = FakeScreen()
|
||||
var started = 0
|
||||
val controller = controllerOn(screen) {
|
||||
started++
|
||||
delay(WORK_MS)
|
||||
screen.churn()
|
||||
true
|
||||
}
|
||||
val seen = recordVisibility(controller)
|
||||
|
||||
// Reconnect and the rebuild event arriving together is what made the
|
||||
// old Boolean flag clear mid-update: whichever pull finished first
|
||||
// wiped the veil off while the other was still running.
|
||||
controller.request()
|
||||
runCurrent()
|
||||
controller.request()
|
||||
advanceTimeBy(WORK_MS + 1)
|
||||
assertTrue(controller.visible.value, "second trigger extends the same veil")
|
||||
|
||||
advanceTimeBy(DRAIN_MS)
|
||||
assertEquals(2, started, "the mid-session trigger still did its pull")
|
||||
assertEquals(listOf(false, true, false), seen, "one veil session, not two")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a never-settling screen still releases the veil at the ceiling`() = runTest {
|
||||
val screen = FakeScreen()
|
||||
val controller = controllerOn(screen) {
|
||||
screen.churn()
|
||||
true
|
||||
}
|
||||
|
||||
screen.artLoading(1) // an image that never completes
|
||||
controller.request()
|
||||
advanceTimeBy(timings.maxHoldMs + 1)
|
||||
assertFalse(controller.visible.value, "the hard ceiling must never strand the user")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unchanged refresh never raises the veil and reports UNCHANGED`() = runTest {
|
||||
val screen = FakeScreen()
|
||||
val results = mutableListOf<VeilSessionResult>()
|
||||
// Succeeds without writing anything — the common case on a launch
|
||||
// over a warm cache, where the server returns what's already cached.
|
||||
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
|
||||
val seen = recordVisibility(controller)
|
||||
|
||||
controller.request(userInitiated = true)
|
||||
advanceTimeBy(DRAIN_MS)
|
||||
|
||||
assertEquals(listOf(false), seen, "a veil over an unchanged screen would hide nothing")
|
||||
assertEquals(VeilOutcome.UNCHANGED, results.single().outcome)
|
||||
assertTrue(results.single().userInitiated, "so the caller can say 'already up to date'")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cached content painting is not mistaken for a change`() = runTest {
|
||||
// Warm cache that hasn't painted yet: the rows arrive a moment after
|
||||
// the session starts. Treating that first paint as churn would veil
|
||||
// every single launch.
|
||||
val screen = FakeScreen(hasContent = false)
|
||||
val controller = controllerOn(screen) { true }
|
||||
|
||||
controller.request()
|
||||
runCurrent()
|
||||
screen.contentAppears()
|
||||
advanceTimeBy(DRAIN_MS)
|
||||
|
||||
assertFalse(controller.visible.value, "first paint is not churn")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a background trigger coalescing with the user's does not swallow their answer`() =
|
||||
runTest {
|
||||
val screen = FakeScreen()
|
||||
val results = mutableListOf<VeilSessionResult>()
|
||||
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
|
||||
|
||||
// Conflation drops the older token, so the "a user asked" bit
|
||||
// cannot ride on it — it's tracked separately for exactly this.
|
||||
controller.request(userInitiated = true)
|
||||
controller.request()
|
||||
advanceTimeBy(DRAIN_MS)
|
||||
|
||||
assertTrue(
|
||||
results.first().userInitiated,
|
||||
"the user's request must not be conflated away",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cold load runs unveiled and is never reported as already up to date`() = runTest {
|
||||
val screen = FakeScreen()
|
||||
val results = mutableListOf<VeilSessionResult>()
|
||||
var ran = false
|
||||
val controller = controllerOn(
|
||||
screen,
|
||||
shouldVeil = { false }, // empty cache: the skeleton owns this
|
||||
onSessionEnd = { results += it },
|
||||
) {
|
||||
ran = true
|
||||
true
|
||||
}
|
||||
|
||||
controller.request(userInitiated = true)
|
||||
advanceTimeBy(DRAIN_MS)
|
||||
|
||||
assertTrue(ran, "the refresh still happens")
|
||||
assertFalse(controller.visible.value, "but no veil over a skeleton")
|
||||
// It went from nothing to something — that IS a change.
|
||||
assertEquals(VeilOutcome.CHANGED, results.single().outcome)
|
||||
}
|
||||
}
|
||||
@@ -44,4 +44,64 @@ None.
|
||||
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
||||
- **In-app update channel polling.** `release.yml` polls Gitea's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure).
|
||||
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
||||
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
|
||||
```yaml
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||
```
|
||||
Upstream's `@v4+` cannot work against this instance and no server-side change
|
||||
will help: `isGhes()` rejects any hostname that isn't `github.com` /
|
||||
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
|
||||
server is never asked what it supports. `@v3` is worse — it reports success,
|
||||
and Gitea then serves artifacts back only through the v4 API
|
||||
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
|
||||
to every retrieval path. A green job producing nothing retrievable; that is how
|
||||
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
|
||||
|
||||
Both are pull mirrors of the Forgejo project's forks
|
||||
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
|
||||
disabling that check), mirrored so CI depends on commits we hold and pinned by
|
||||
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
|
||||
otherwise silently change what runs.
|
||||
|
||||
**Match the pins on `@actions/artifact`, not on the actions' own version
|
||||
numbers.** The two actions release on unrelated cadences, so equal version
|
||||
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
|
||||
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
|
||||
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
|
||||
matters because `release.yml` is a producer/consumer pair — `android-release`
|
||||
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
|
||||
across it yields an empty listing rather than an error, exactly the silent
|
||||
failure this entry exists to prevent.
|
||||
|
||||
| tag | `@actions/artifact` | runtime |
|
||||
|---|---|---|
|
||||
| upload v4 | ^2.1.1 | node20 |
|
||||
| **upload v5** ← pinned | **^4.0.0** | node20 |
|
||||
| download v4 | ^2.1.1 | node20 |
|
||||
| download v5 | ^2.3.2 | node20 |
|
||||
| **download v6** ← pinned | **^4.0.0** | node20 |
|
||||
| download v7 | ^5.0.0 | **node24** |
|
||||
|
||||
The only true protocol break in this history was **v3 → v4** (upstream:
|
||||
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
|
||||
below are not supported"); v4-and-up are one family. Later majors are mostly
|
||||
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
|
||||
at 500 artifacts; download v5 made by-ID extraction match by-name.
|
||||
|
||||
**Do not jump the download pin to v7.** That major is a runner requirement, not
|
||||
a feature change: it moves to `runs.using: node24` and upstream states it
|
||||
"requires a minimum Actions Runner version of 2.327.1 … if you are using
|
||||
self-hosted runners, ensure they are updated before upgrading." act_runner is
|
||||
not GitHub's runner and makes no such version claim, so node24 is unverified
|
||||
here. Everything currently pinned is node20.
|
||||
|
||||
Upload steps set `if-no-files-found: error` rather than the default `warn`, so
|
||||
an upload that matches nothing fails its own job instead of failing the
|
||||
consumer later.
|
||||
|
||||
Retrieval: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
|
||||
for the id (global run id, not the repo-scoped run number), then
|
||||
`…/actions/artifacts/{id}/zip`. The workstation has no `unzip` — use
|
||||
`python3 -m zipfile -e`.
|
||||
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
|
||||
|
||||
+18
-8
@@ -6,17 +6,27 @@ in-app update flow (#397).
|
||||
|
||||
## Production
|
||||
|
||||
CI populates this directory on tag releases:
|
||||
1. `flutter.yml` builds `app-release.apk` and attaches it to the
|
||||
Gitea release as `minstrel-<TAG>.apk`.
|
||||
2. `release.yml` waits for that asset to appear, downloads it into
|
||||
this directory as `minstrel.apk`, writes the tag string to
|
||||
`minstrel.apk.version`, and `docker buildx build` includes both
|
||||
via `COPY client/ /app/client/`.
|
||||
CI populates this directory in `release.yml` (no Flutter — the client is
|
||||
native Android, built by the `android-release` job):
|
||||
|
||||
- **Tag release (`v*`):** the `android-release` job builds + signs the
|
||||
APK, uploads it as a workflow artifact, and attaches it to the Gitea
|
||||
release as `minstrel-<TAG>.apk`. The `image-release` job
|
||||
(`needs: android-release`) downloads that artifact into this directory
|
||||
as `minstrel.apk` and writes the computed versionName to
|
||||
`minstrel.apk.version`.
|
||||
- **Main push (non-tag `:latest` / `:main`):** no APK is built, but the
|
||||
image still moves `:latest`, so `image-release` pulls the most-recent
|
||||
release's signed APK from the Gitea API and reconstructs its exact
|
||||
versionName — keeping the in-app update channel alive on `:latest`
|
||||
instead of stripping it until the next tag.
|
||||
|
||||
Either way, `docker buildx build` bakes both files into the image via
|
||||
`COPY client/ /app/client/`.
|
||||
|
||||
## Development
|
||||
|
||||
Empty directory works fine — the endpoints return 404 and the Flutter
|
||||
Empty directory works fine — the endpoints return 404 and the Android
|
||||
update banner stays hidden ("no update channel" graceful degradation).
|
||||
|
||||
To smoke-test the update flow locally, drop a real APK + a version
|
||||
|
||||
+46
-1
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
@@ -25,12 +26,14 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -146,6 +149,16 @@ func run() error {
|
||||
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
||||
coverEnricher.DataDir = cfg.Storage.DataDir
|
||||
|
||||
// Recommendation tuning lab (#1250): seeds shipped defaults on first
|
||||
// boot and pushes the daily_mix weights + taste config into package
|
||||
// playlists — must precede the scheduler so the first builds score
|
||||
// with the operator's tuned values, not the pre-push literals.
|
||||
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
|
||||
if err != nil {
|
||||
logger.Error("recommendation settings service init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
||||
// Boot-time scan and manual-trigger scans share this path; results land
|
||||
// in scan_runs for the admin overview.
|
||||
@@ -194,6 +207,31 @@ func run() error {
|
||||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||||
go similarityWorker.Run(ctx)
|
||||
|
||||
// Start the household co-play worker (#1533). Recomputes artist–artist
|
||||
// co-occurrence edges (source='user_cooccurrence') from play_events every
|
||||
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
|
||||
// SQL, no external calls; empty on single-user servers.
|
||||
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
|
||||
|
||||
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
||||
// tag providers with tag_provider_settings, bumps the sources version if
|
||||
// the provider set changed (re-opening settled rows), then drains tracks
|
||||
// needing folksonomy tags on a periodic tick. Standalone (not in the file
|
||||
// scan chain) because tag lookups need only DB fields — MBID / artist /
|
||||
// title — that a scan has already imported.
|
||||
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
|
||||
if err != nil {
|
||||
logger.Error("tag settings service init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
|
||||
logger.Warn("tags: provider-hash boot check failed", "err", berr)
|
||||
} else if bumped {
|
||||
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
|
||||
}
|
||||
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
|
||||
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
|
||||
|
||||
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
||||
// that have no writer-side close path or retention policy:
|
||||
// orphan play_events, stale play_sessions, expired
|
||||
@@ -242,7 +280,12 @@ func run() error {
|
||||
// active user's daily build at 03:00 in their stored timezone.
|
||||
// Replaces the 24h-anchored cron loop (removed in the next commit
|
||||
// of this arc).
|
||||
playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir)
|
||||
playlistScheduler, err := playlists.NewScheduler(
|
||||
pool,
|
||||
logger.With("component", "playlist_scheduler"),
|
||||
cfg.Storage.DataDir,
|
||||
bus,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init playlist scheduler: %w", err)
|
||||
}
|
||||
@@ -289,6 +332,8 @@ func run() error {
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||||
srv.Bus = bus
|
||||
srv.PlaylistScheduler = playlistScheduler
|
||||
srv.RecSettings = recSettings
|
||||
srv.TagSettings = tagSettings
|
||||
srv.StreamSecret = cfg.StreamSecret
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Server.Address,
|
||||
|
||||
+7
-2
@@ -26,8 +26,13 @@ library:
|
||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||
scan_paths: []
|
||||
# Kick off a scan on server startup. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
|
||||
scan_on_startup: false
|
||||
# Kick off a scan on server startup. On by default so a fresh install
|
||||
# populates its library on first boot with no manual step. Scans are
|
||||
# incremental (unchanged files are skipped by mtime), so the cost on
|
||||
# every later restart is just a directory walk, not a full re-index.
|
||||
# Set false if you'd rather scan only on a schedule / via the admin UI.
|
||||
# Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
|
||||
scan_on_startup: true
|
||||
|
||||
# Per-instance branding. Operators can rename their instance ("Family
|
||||
# Jukebox", "Office Music", etc.) and override the OG share-preview
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# README screenshots
|
||||
|
||||
Drop the files below into this directory with these **exact names** — the
|
||||
root `README.md` already references them, so no Markdown editing is needed.
|
||||
Export as PNG. Capture while logged in as admin and *after* the first
|
||||
library scan so nothing looks empty; keep the same browser width and theme
|
||||
across all shots for a consistent look.
|
||||
|
||||
| Filename | Route | What to capture |
|
||||
|---|---|---|
|
||||
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
|
||||
| `register.png` | `/register` | The account-creation form on a fresh instance |
|
||||
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
|
||||
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
|
||||
| `android-download.png` | `/settings` | The "Install the Android app" download card |
|
||||
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
|
||||
|
||||
Tip: to control display width in the README, you can swap the Markdown
|
||||
`` for an HTML tag, e.g. `<img src="docs/screenshots/home.png" width="800" alt="...">`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,202 @@
|
||||
// Admin recommendation-trends endpoint (#1251): weekly per-surface
|
||||
// outcome series with tuning-audit markers — the verify half of the
|
||||
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
|
||||
// all users because the knobs are global; rows carry rates only.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
const (
|
||||
trendsDefaultWeeks = 12
|
||||
trendsMaxWeeks = 52
|
||||
// trendsAuditFetchCap bounds the audit fetch; markers older than
|
||||
// the window are dropped in Go. Far above any real knob-turn count
|
||||
// inside a year.
|
||||
trendsAuditFetchCap = 500
|
||||
)
|
||||
|
||||
// trendPoint is one week of one surface family's outcomes.
|
||||
type trendPoint struct {
|
||||
WeekStart string `json:"week_start"` // ISO date (Monday)
|
||||
Plays int64 `json:"plays"`
|
||||
Skips int64 `json:"skips"`
|
||||
SkipRate float64 `json:"skip_rate"`
|
||||
AvgCompletion float64 `json:"avg_completion"`
|
||||
// TasteHitRate is the share of plays whose track's artist carries a
|
||||
// positive weight in the player's current taste profile — a drifted
|
||||
// but retroactive read on whether the surface feeds taste-fitting
|
||||
// tracks.
|
||||
TasteHitRate float64 `json:"taste_hit_rate"`
|
||||
// completionN carries the completion sample count through same-week
|
||||
// merges so avg_completion stays sample-weighted; not serialized.
|
||||
completionN int64
|
||||
}
|
||||
|
||||
// trendSeries is one surface family's weekly series.
|
||||
type trendSeries struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
|
||||
Plays int64 `json:"plays"` // window total, for sorting/volume-gating
|
||||
Points []trendPoint `json:"points"`
|
||||
}
|
||||
|
||||
// trendMarker is one tuning-audit event annotated on the timeline.
|
||||
type trendMarker struct {
|
||||
ChangedAt string `json:"changed_at"`
|
||||
Scope string `json:"scope"`
|
||||
Action string `json:"action"`
|
||||
Changes json.RawMessage `json:"changes"`
|
||||
}
|
||||
|
||||
type trendsResp struct {
|
||||
Weeks int `json:"weeks"`
|
||||
Series []trendSeries `json:"series"`
|
||||
Markers []trendMarker `json:"markers"`
|
||||
}
|
||||
|
||||
// handleGetRecommendationTrends implements
|
||||
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
|
||||
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
|
||||
weeks := trendsDefaultWeeks
|
||||
if v := r.URL.Query().Get("weeks"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
|
||||
return
|
||||
}
|
||||
if n > trendsMaxWeeks {
|
||||
n = trendsMaxWeeks
|
||||
}
|
||||
weeks = n
|
||||
}
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
|
||||
if err != nil {
|
||||
h.logger.Error("api: recommendation trends", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
|
||||
if err != nil {
|
||||
h.logger.Error("api: recommendation trends audit", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
|
||||
}
|
||||
|
||||
// buildTrendsResponse folds weekly rows into per-family series and
|
||||
// windows the audit markers. Split from the handler for pure-unit
|
||||
// testability.
|
||||
func buildTrendsResponse(
|
||||
weeks int, now time.Time,
|
||||
rows []dbq.RecommendationWeeklyTrendsRow,
|
||||
audits []dbq.RecommendationTuningAudit,
|
||||
) trendsResp {
|
||||
type accum struct {
|
||||
fam recFamily
|
||||
plays int64
|
||||
points []trendPoint
|
||||
}
|
||||
families := map[string]*accum{}
|
||||
for _, row := range rows {
|
||||
fam := recFamily{key: "manual", label: "Manual library plays"}
|
||||
if row.Source != nil && *row.Source != "" {
|
||||
fam = bucketRecSource(*row.Source)
|
||||
}
|
||||
acc, ok := families[fam.key]
|
||||
if !ok {
|
||||
acc = &accum{fam: fam}
|
||||
families[fam.key] = acc
|
||||
}
|
||||
acc.plays += row.Plays
|
||||
p := trendPoint{
|
||||
WeekStart: row.WeekStart.Time.Format("2006-01-02"),
|
||||
Plays: row.Plays,
|
||||
Skips: row.Skips,
|
||||
AvgCompletion: row.AvgCompletion,
|
||||
completionN: row.CompletionN,
|
||||
}
|
||||
if row.Plays > 0 {
|
||||
p.SkipRate = float64(row.Skips) / float64(row.Plays)
|
||||
p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
|
||||
}
|
||||
// Same family can arrive as several raw sources (radio:<uuid>);
|
||||
// merge same-week points play-weighted.
|
||||
if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
|
||||
acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
|
||||
} else {
|
||||
acc.points = append(acc.points, p)
|
||||
}
|
||||
}
|
||||
|
||||
resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
|
||||
for _, acc := range families {
|
||||
resp.Series = append(resp.Series, trendSeries{
|
||||
Key: acc.fam.key,
|
||||
Label: acc.fam.label,
|
||||
Intent: acc.fam.intent,
|
||||
Plays: acc.plays,
|
||||
Points: acc.points,
|
||||
})
|
||||
}
|
||||
sort.Slice(resp.Series, func(i, j int) bool {
|
||||
if resp.Series[i].Plays != resp.Series[j].Plays {
|
||||
return resp.Series[i].Plays > resp.Series[j].Plays
|
||||
}
|
||||
return resp.Series[i].Key < resp.Series[j].Key
|
||||
})
|
||||
|
||||
cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
|
||||
for _, a := range audits {
|
||||
if a.ChangedAt.Time.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
resp.Markers = append(resp.Markers, trendMarker{
|
||||
ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
|
||||
Scope: a.Scope,
|
||||
Action: a.Action,
|
||||
Changes: json.RawMessage(a.Changes),
|
||||
})
|
||||
}
|
||||
// ListTuningAudit returns newest-first; the timeline reads better
|
||||
// oldest-first.
|
||||
sort.Slice(resp.Markers, func(i, j int) bool {
|
||||
return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
|
||||
})
|
||||
return resp
|
||||
}
|
||||
|
||||
// mergeTrendPoints combines two same-week points of one family:
|
||||
// counts add, skip/taste rates re-derive from the merged counts, and
|
||||
// avg_completion is weighted by each side's completion sample count.
|
||||
func mergeTrendPoints(a, b trendPoint) trendPoint {
|
||||
out := trendPoint{
|
||||
WeekStart: a.WeekStart,
|
||||
Plays: a.Plays + b.Plays,
|
||||
Skips: a.Skips + b.Skips,
|
||||
completionN: a.completionN + b.completionN,
|
||||
}
|
||||
if out.Plays > 0 {
|
||||
out.SkipRate = float64(out.Skips) / float64(out.Plays)
|
||||
out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
|
||||
float64(out.Plays)
|
||||
}
|
||||
if out.completionN > 0 {
|
||||
out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
|
||||
float64(out.completionN)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func date(s string) pgtype.Date {
|
||||
t, _ := time.Parse("2006-01-02", s)
|
||||
return pgtype.Date{Time: t, Valid: true}
|
||||
}
|
||||
|
||||
func TestBuildTrendsResponse_SeriesMergingAndMarkers(t *testing.T) {
|
||||
src := func(s string) *string { return &s }
|
||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
rows := []dbq.RecommendationWeeklyTrendsRow{
|
||||
// Two radio session sources in the same week collapse into one
|
||||
// family point; completion weighting by sample count.
|
||||
{WeekStart: date("2026-06-22"), Source: src("radio:aaaa"),
|
||||
Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 0.6, TasteHits: 3},
|
||||
{WeekStart: date("2026-06-22"), Source: src("radio:bbbb"),
|
||||
Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2, TasteHits: 0},
|
||||
{WeekStart: date("2026-06-29"), Source: src("radio:aaaa"),
|
||||
Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9, TasteHits: 1},
|
||||
// NULL source = manual baseline family.
|
||||
{WeekStart: date("2026-06-29"), Source: nil,
|
||||
Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.8, TasteHits: 4},
|
||||
}
|
||||
audits := []dbq.RecommendationTuningAudit{
|
||||
{ID: 2, ChangedAt: pgtype.Timestamptz{Time: now.Add(-24 * time.Hour), Valid: true},
|
||||
Scope: "radio", Action: "update", Changes: []byte(`[{"field":"taste_weight","old":1,"new":2}]`)},
|
||||
// Older than the window → dropped.
|
||||
{ID: 1, ChangedAt: pgtype.Timestamptz{Time: now.Add(-100 * 7 * 24 * time.Hour), Valid: true},
|
||||
Scope: "taste", Action: "reset", Changes: []byte(`[]`)},
|
||||
}
|
||||
|
||||
resp := buildTrendsResponse(12, now, rows, audits)
|
||||
|
||||
if len(resp.Series) != 2 {
|
||||
t.Fatalf("series = %d, want 2 (radio + manual)", len(resp.Series))
|
||||
}
|
||||
radio := resp.Series[0] // 6 plays > manual's 5 → sorted first
|
||||
if radio.Key != "radio" || radio.Plays != 6 {
|
||||
t.Fatalf("series[0] = %s/%d, want radio/6", radio.Key, radio.Plays)
|
||||
}
|
||||
if len(radio.Points) != 2 {
|
||||
t.Fatalf("radio points = %d, want 2 weeks", len(radio.Points))
|
||||
}
|
||||
wk1 := radio.Points[0]
|
||||
if wk1.WeekStart != "2026-06-22" || wk1.Plays != 4 || wk1.Skips != 2 {
|
||||
t.Errorf("week1 = %+v, want 2026-06-22 with 4 plays / 2 skips", wk1)
|
||||
}
|
||||
if wk1.SkipRate != 0.5 {
|
||||
t.Errorf("week1 skip_rate = %v, want 0.5", wk1.SkipRate)
|
||||
}
|
||||
// Completion weighted by sample count: (0.6*3 + 0.2*1) / 4 = 0.5.
|
||||
if wk1.AvgCompletion < 0.49 || wk1.AvgCompletion > 0.51 {
|
||||
t.Errorf("week1 avg_completion = %v, want 0.5", wk1.AvgCompletion)
|
||||
}
|
||||
// Taste hits: 3 of 4 plays.
|
||||
if wk1.TasteHitRate != 0.75 {
|
||||
t.Errorf("week1 taste_hit_rate = %v, want 0.75", wk1.TasteHitRate)
|
||||
}
|
||||
if manual := resp.Series[1]; manual.Key != "manual" || manual.Intent != "" {
|
||||
t.Errorf("series[1] = %+v, want the manual baseline family", manual)
|
||||
}
|
||||
|
||||
if len(resp.Markers) != 1 {
|
||||
t.Fatalf("markers = %d, want 1 (out-of-window marker dropped)", len(resp.Markers))
|
||||
}
|
||||
if resp.Markers[0].Scope != "radio" || resp.Markers[0].Action != "update" {
|
||||
t.Errorf("marker = %+v, want radio/update", resp.Markers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func newTrendsRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/admin/recommendation-trends", h.handleGetRecommendationTrends)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRecommendationTrends_EndToEnd(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "trends", "pw", true)
|
||||
artist := seedArtist(t, pool, "TrendArtist")
|
||||
album := seedAlbum(t, pool, artist.ID, "TrendAlbum", 2020)
|
||||
tk := seedTrack(t, pool, album.ID, artist.ID, "TrendTrack", 1, 200000)
|
||||
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||
|
||||
// Positive taste weight for the artist → plays count as taste hits.
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 1.5)`,
|
||||
user.ID, artist.ID); err != nil {
|
||||
t.Fatalf("seed taste profile: %v", err)
|
||||
}
|
||||
radio := "radio"
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radio, nil, 0.9, false)
|
||||
// A knob turn to mark the timeline.
|
||||
if err := h.recSettings.UpdateProfile(context.Background(), "radio",
|
||||
map[string]float64{"taste_weight": 2}); err != nil {
|
||||
t.Fatalf("UpdateProfile: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newTrendsRouter(h).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp trendsResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Weeks != trendsDefaultWeeks {
|
||||
t.Errorf("weeks = %d, want %d", resp.Weeks, trendsDefaultWeeks)
|
||||
}
|
||||
var radioSeries *trendSeries
|
||||
for i := range resp.Series {
|
||||
if resp.Series[i].Key == "radio" {
|
||||
radioSeries = &resp.Series[i]
|
||||
}
|
||||
}
|
||||
if radioSeries == nil || len(radioSeries.Points) != 1 {
|
||||
t.Fatalf("radio series = %+v, want one point", radioSeries)
|
||||
}
|
||||
if radioSeries.Points[0].TasteHitRate != 1.0 {
|
||||
t.Errorf("taste_hit_rate = %v, want 1.0 (positive-weight artist)",
|
||||
radioSeries.Points[0].TasteHitRate)
|
||||
}
|
||||
if len(resp.Markers) != 1 || resp.Markers[0].Scope != "radio" {
|
||||
t.Errorf("markers = %+v, want the radio knob turn", resp.Markers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationTrends_InvalidWeeks(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends?weeks=zero", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newTrendsRouter(h).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Admin recommendation-tuning endpoints (#1250): the defaults-
|
||||
// discovery lab. GET returns current values + shipped defaults for
|
||||
// every scope; PATCH applies a partial update to one scope; reset
|
||||
// restores a scope to shipped defaults. Every change writes an audit
|
||||
// row (consumed by the metrics trend view, #1251).
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
)
|
||||
|
||||
// weightsResp is one weight profile on the wire, keyed by the same
|
||||
// snake_case field names the PATCH body accepts.
|
||||
type weightsResp struct {
|
||||
BaseWeight float64 `json:"base_weight"`
|
||||
LikeBoost float64 `json:"like_boost"`
|
||||
RecencyWeight float64 `json:"recency_weight"`
|
||||
SkipPenalty float64 `json:"skip_penalty"`
|
||||
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||
ContextWeight float64 `json:"context_weight"`
|
||||
SimilarityWeight float64 `json:"similarity_weight"`
|
||||
TasteWeight float64 `json:"taste_weight"`
|
||||
ContextTimeWeight float64 `json:"context_time_weight"`
|
||||
}
|
||||
|
||||
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
||||
return weightsResp{
|
||||
BaseWeight: w.BaseWeight,
|
||||
LikeBoost: w.LikeBoost,
|
||||
RecencyWeight: w.RecencyWeight,
|
||||
SkipPenalty: w.SkipPenalty,
|
||||
JitterMagnitude: w.JitterMagnitude,
|
||||
ContextWeight: w.ContextWeight,
|
||||
SimilarityWeight: w.SimilarityWeight,
|
||||
TasteWeight: w.TasteWeight,
|
||||
ContextTimeWeight: w.ContextTimeWeight,
|
||||
}
|
||||
}
|
||||
|
||||
type tasteTuningResp struct {
|
||||
HalfLifeDays float64 `json:"half_life_days"`
|
||||
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||
EngagementFull float64 `json:"engagement_full"`
|
||||
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
||||
EraScale float64 `json:"era_scale"`
|
||||
MoodScale float64 `json:"mood_scale"`
|
||||
}
|
||||
|
||||
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||
return tasteTuningResp{
|
||||
HalfLifeDays: t.HalfLifeDays,
|
||||
EngagementHardSkip: t.EngagementHardSkip,
|
||||
EngagementNeutral: t.EngagementNeutral,
|
||||
EngagementFull: t.EngagementFull,
|
||||
EnrichedTagScale: t.EnrichedTagScale,
|
||||
EraScale: t.EraScale,
|
||||
MoodScale: t.MoodScale,
|
||||
}
|
||||
}
|
||||
|
||||
// tuningSnapshot is both the GET response and the post-mutation echo:
|
||||
// current values alongside shipped defaults so the card can mark
|
||||
// which knobs deviate.
|
||||
type tuningSnapshot struct {
|
||||
Profiles map[string]weightsResp `json:"profiles"`
|
||||
Taste tasteTuningResp `json:"taste"`
|
||||
Shipped struct {
|
||||
Profiles map[string]weightsResp `json:"profiles"`
|
||||
Taste tasteTuningResp `json:"taste"`
|
||||
} `json:"shipped"`
|
||||
}
|
||||
|
||||
func (h *handlers) tuningSnapshot() tuningSnapshot {
|
||||
var out tuningSnapshot
|
||||
out.Profiles = map[string]weightsResp{
|
||||
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||
}
|
||||
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||
out.Shipped.Profiles = map[string]weightsResp{
|
||||
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||
}
|
||||
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||
return out
|
||||
}
|
||||
|
||||
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
|
||||
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||
}
|
||||
|
||||
// patchTuningReq carries the partial update: field name → new value,
|
||||
// using the same snake_case names the GET response emits.
|
||||
type patchTuningReq struct {
|
||||
Values map[string]float64 `json:"values"`
|
||||
}
|
||||
|
||||
// handlePatchRecommendationTuning implements
|
||||
// PATCH /api/admin/recommendation-tuning/{scope}.
|
||||
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||
scope := chi.URLParam(r, "scope")
|
||||
var body patchTuningReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||
return
|
||||
}
|
||||
if len(body.Values) == 0 {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if scope == recsettings.ScopeTaste {
|
||||
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
||||
} else {
|
||||
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
|
||||
}
|
||||
if err != nil {
|
||||
writeTuningErr(w, h, scope, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||
}
|
||||
|
||||
// handleResetRecommendationTuning implements
|
||||
// POST /api/admin/recommendation-tuning/{scope}/reset.
|
||||
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||
scope := chi.URLParam(r, "scope")
|
||||
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
|
||||
writeTuningErr(w, h, scope, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||
}
|
||||
|
||||
// writeTuningErr maps recsettings validation errors to 400s and
|
||||
// everything else to a logged 500.
|
||||
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, recsettings.ErrUnknownScope):
|
||||
writeErr(w, &apierror.Error{
|
||||
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
|
||||
})
|
||||
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
|
||||
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
|
||||
default:
|
||||
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
|
||||
writeErr(w, apierror.InternalMsg("tuning update failed", err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
)
|
||||
|
||||
func newTuningRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||
return r
|
||||
}
|
||||
|
||||
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
|
||||
t.Helper()
|
||||
var snap tuningSnapshot
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newTuningRouter(h).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
snap := decodeTuning(t, rec)
|
||||
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
|
||||
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
|
||||
}
|
||||
if snap.Taste.HalfLifeDays != 75 {
|
||||
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
|
||||
}
|
||||
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
|
||||
t.Error("untouched values must equal shipped defaults")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
r := newTuningRouter(h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
|
||||
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
snap := decodeTuning(t, rec)
|
||||
if snap.Profiles["radio"].TasteWeight != 2.5 {
|
||||
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
|
||||
}
|
||||
// The change is live for the radio scoring path.
|
||||
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
|
||||
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("reset status = %d, want 200", rec.Code)
|
||||
}
|
||||
snap = decodeTuning(t, rec)
|
||||
if snap.Profiles["radio"].TasteWeight != 1.0 {
|
||||
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationTuning_PatchErrors(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
r := newTuningRouter(h)
|
||||
cases := []struct {
|
||||
name, path, body string
|
||||
want int
|
||||
}{
|
||||
{"unknown scope", "/api/admin/recommendation-tuning/banana",
|
||||
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
|
||||
{"unknown field", "/api/admin/recommendation-tuning/radio",
|
||||
`{"values":{"vibes":1}}`, http.StatusBadRequest},
|
||||
{"out of range", "/api/admin/recommendation-tuning/taste",
|
||||
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
|
||||
{"empty values", "/api/admin/recommendation-tuning/radio",
|
||||
`{"values":{}}`, http.StatusBadRequest},
|
||||
{"bad json", "/api/admin/recommendation-tuning/radio",
|
||||
`{`, http.StatusBadRequest},
|
||||
}
|
||||
for _, c := range cases {
|
||||
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != c.want {
|
||||
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
// tagSourceResp is the wire shape for one tag-enrichment provider in the
|
||||
// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface
|
||||
// (admin_cover_sources.go) — a separate, independent settings card so a new
|
||||
// tag source is added without touching art settings (#1490).
|
||||
type tagSourceResp struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RequiresAPIKey bool `json:"requires_api_key"`
|
||||
Supports []string `json:"supports"`
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
DisplayOrder int32 `json:"display_order"`
|
||||
Testable bool `json:"testable"`
|
||||
}
|
||||
|
||||
func tagSourceRespFrom(info tags.ProviderInfo) tagSourceResp {
|
||||
supports := info.Supports
|
||||
if supports == nil {
|
||||
supports = []string{}
|
||||
}
|
||||
return tagSourceResp{
|
||||
ID: info.ID,
|
||||
DisplayName: info.DisplayName,
|
||||
RequiresAPIKey: info.RequiresAPIKey,
|
||||
Supports: supports,
|
||||
Enabled: info.Enabled,
|
||||
APIKeySet: info.APIKeySet,
|
||||
DisplayOrder: info.DisplayOrder,
|
||||
Testable: info.Testable,
|
||||
}
|
||||
}
|
||||
|
||||
type tagSourcesListResp struct {
|
||||
Providers []tagSourceResp `json:"providers"`
|
||||
SourcesVersion int32 `json:"sources_version"`
|
||||
}
|
||||
|
||||
// handleListTagSources implements GET /api/admin/tag-sources.
|
||||
func (h *handlers) handleListTagSources(w http.ResponseWriter, _ *http.Request) {
|
||||
infos := h.tagSettings.ListProviderInfo()
|
||||
out := tagSourcesListResp{
|
||||
Providers: make([]tagSourceResp, 0, len(infos)),
|
||||
SourcesVersion: h.tagSettings.CurrentVersion(),
|
||||
}
|
||||
for _, info := range infos {
|
||||
out.Providers = append(out.Providers, tagSourceRespFrom(info))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// updateTagSourceReq is the PATCH body. nil pointers mean "leave
|
||||
// unchanged"; non-nil sets (empty api_key clears).
|
||||
type updateTagSourceReq struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
}
|
||||
|
||||
type updateTagSourceResp struct {
|
||||
tagSourceResp
|
||||
VersionBumped bool `json:"version_bumped"`
|
||||
}
|
||||
|
||||
// handleUpdateTagSource implements PATCH /api/admin/tag-sources/{provider_id}.
|
||||
func (h *handlers) handleUpdateTagSource(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "provider_id")
|
||||
|
||||
var body updateTagSourceReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||
return
|
||||
}
|
||||
|
||||
bumped, err := h.tagSettings.UpdateProvider(r.Context(), id, tags.ProviderUpdatePatch{
|
||||
Enabled: body.Enabled,
|
||||
APIKey: body.APIKey,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, tags.ErrProviderNotFound) {
|
||||
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin: update tag source", "id", id, "err", err)
|
||||
writeErr(w, apierror.InternalMsg("update failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
var found tagSourceResp
|
||||
for _, info := range h.tagSettings.ListProviderInfo() {
|
||||
if info.ID == id {
|
||||
found = tagSourceRespFrom(info)
|
||||
break
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updateTagSourceResp{tagSourceResp: found, VersionBumped: bumped})
|
||||
}
|
||||
|
||||
type testTagSourceResp struct {
|
||||
OK bool `json:"ok"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test.
|
||||
func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "provider_id")
|
||||
|
||||
start := time.Now()
|
||||
err := h.tagSettings.TestProvider(r.Context(), id)
|
||||
duration := time.Since(start).Milliseconds()
|
||||
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, testTagSourceResp{OK: true, DurationMs: duration})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, tags.ErrProviderNotFound) {
|
||||
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
|
||||
return
|
||||
}
|
||||
// Not testable, or the test failed — both surface as ok=false with a
|
||||
// 200 (the operation completed; the result is data, not an error).
|
||||
writeJSON(w, http.StatusOK, testTagSourceResp{OK: false, DurationMs: duration, Error: err.Error()})
|
||||
}
|
||||
|
||||
type researchTagsResp struct {
|
||||
SourcesVersion int32 `json:"sources_version"`
|
||||
}
|
||||
|
||||
// handleResearchTags implements POST /api/admin/tag-sources/research: bump
|
||||
// the sources version so every settled ('none') track becomes eligible for
|
||||
// a re-enrichment pass, then the background worker re-processes them.
|
||||
func (h *handlers) handleResearchTags(w http.ResponseWriter, r *http.Request) {
|
||||
newVer, err := h.tagSettings.BumpVersion(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("admin: research tags", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("research failed", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, researchTagsResp{SourcesVersion: newVer})
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
// apiTestTagProvider is a minimal TrackTagProvider for api-package tests.
|
||||
// Does not implement TestableProvider.
|
||||
type apiTestTagProvider struct {
|
||||
id string
|
||||
display string
|
||||
}
|
||||
|
||||
func (p *apiTestTagProvider) ID() string { return p.id }
|
||||
func (p *apiTestTagProvider) DisplayName() string { return p.display }
|
||||
func (p *apiTestTagProvider) RequiresAPIKey() bool { return false }
|
||||
func (p *apiTestTagProvider) DefaultEnabled() bool { return true }
|
||||
func (p *apiTestTagProvider) Configure(_ tags.ProviderSettings) error { return nil }
|
||||
func (p *apiTestTagProvider) FetchTrackTags(_ context.Context, _ tags.TrackRef) ([]tags.Tag, error) {
|
||||
return []tags.Tag{{Name: "x", Weight: 1}}, nil
|
||||
}
|
||||
|
||||
// apiTestTestableTagProvider also implements TestableProvider.
|
||||
type apiTestTestableTagProvider struct {
|
||||
apiTestTagProvider
|
||||
}
|
||||
|
||||
func (p *apiTestTestableTagProvider) TestConnection(_ context.Context) error { return nil }
|
||||
|
||||
func newAdminTagSourcesRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin())
|
||||
admin.Get("/tag-sources", h.handleListTagSources)
|
||||
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
||||
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// testHandlersWithTagSettings installs a tags.SettingsService backed by the
|
||||
// test DB. Register the needed fake providers BEFORE calling this, and pair
|
||||
// with tags.ResetRegistryForTests in t.Cleanup.
|
||||
func testHandlersWithTagSettings(t *testing.T) *handlers {
|
||||
t.Helper()
|
||||
h, pool := testHandlers(t)
|
||||
s, err := tags.NewSettingsService(context.Background(), pool, h.logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSettingsService: %v", err)
|
||||
}
|
||||
h.tagSettings = s
|
||||
return h
|
||||
}
|
||||
|
||||
func TestAdminListTagSources_ReturnsRegisteredProviders(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
tags.ResetRegistryForTests()
|
||||
t.Cleanup(tags.ResetRegistryForTests)
|
||||
tags.Register(&apiTestTagProvider{id: "test-tag-prov", display: "Test Tag Provider"})
|
||||
|
||||
h := testHandlersWithTagSettings(t)
|
||||
admin := seedUser(t, h.pool, "tslist", "pw", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/tag-sources", nil)
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp tagSourcesListResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Providers) != 1 || resp.Providers[0].ID != "test-tag-prov" {
|
||||
t.Fatalf("providers = %+v, want 1 (test-tag-prov)", resp.Providers)
|
||||
}
|
||||
found := false
|
||||
for _, s := range resp.Providers[0].Supports {
|
||||
if s == "track_tags" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("supports = %v, want to include track_tags", resp.Providers[0].Supports)
|
||||
}
|
||||
if resp.Providers[0].Testable {
|
||||
t.Error("testable = true, want false (no TestConnection)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateTagSource_FlippingEnabledBumpsVersion(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
tags.ResetRegistryForTests()
|
||||
t.Cleanup(tags.ResetRegistryForTests)
|
||||
tags.Register(&apiTestTagProvider{id: "flip-tag", display: "Flip Tag"})
|
||||
|
||||
h := testHandlersWithTagSettings(t)
|
||||
admin := seedUser(t, h.pool, "tsflip", "pw", true)
|
||||
|
||||
disabled := false
|
||||
body, _ := json.Marshal(updateTagSourceReq{Enabled: &disabled})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/flip-tag", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp updateTagSourceResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.VersionBumped {
|
||||
t.Error("version_bumped = false, want true after flipping enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateTagSource_KeyOnlyDoesNotBump(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
tags.ResetRegistryForTests()
|
||||
t.Cleanup(tags.ResetRegistryForTests)
|
||||
tags.Register(&apiTestTagProvider{id: "key-tag", display: "Key Tag"})
|
||||
|
||||
h := testHandlersWithTagSettings(t)
|
||||
admin := seedUser(t, h.pool, "tskey", "pw", true)
|
||||
|
||||
newKey := "newkey"
|
||||
body, _ := json.Marshal(updateTagSourceReq{APIKey: &newKey})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/key-tag", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp updateTagSourceResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.VersionBumped {
|
||||
t.Error("version_bumped = true, want false for key-only change")
|
||||
}
|
||||
if !resp.APIKeySet {
|
||||
t.Error("api_key_set = false, want true after setting key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateTagSource_UnknownProvider404(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
tags.ResetRegistryForTests()
|
||||
t.Cleanup(tags.ResetRegistryForTests)
|
||||
|
||||
h := testHandlersWithTagSettings(t)
|
||||
admin := seedUser(t, h.pool, "ts404", "pw", true)
|
||||
|
||||
body, _ := json.Marshal(updateTagSourceReq{})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/missing", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTestTagSource_NonAdminReturns403(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
tags.ResetRegistryForTests()
|
||||
t.Cleanup(tags.ResetRegistryForTests)
|
||||
tags.Register(&apiTestTestableTagProvider{apiTestTagProvider{id: "testable-tag", display: "Testable Tag"}})
|
||||
|
||||
h := testHandlersWithTagSettings(t)
|
||||
nonAdmin := seedUser(t, h.pool, "tsnoadmin", "pw", false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/testable-tag/test", nil)
|
||||
req = withUser(req, nonAdmin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTestTagSource_NotTestableReturnsOkFalse(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
tags.ResetRegistryForTests()
|
||||
t.Cleanup(tags.ResetRegistryForTests)
|
||||
tags.Register(&apiTestTagProvider{id: "nontestable-tag", display: "Non-Testable Tag"})
|
||||
|
||||
h := testHandlersWithTagSettings(t)
|
||||
admin := seedUser(t, h.pool, "tsntest", "pw", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/nontestable-tag/test", nil)
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp testTagSourceResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.OK {
|
||||
t.Error("ok = true, want false for non-testable provider")
|
||||
}
|
||||
if resp.Error == "" {
|
||||
t.Error("error string empty, want a message")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type adminUserView struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
AutoApproveRequests bool `json:"auto_approve_requests"`
|
||||
DebugModeEnabled bool `json:"debug_mode_enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ func (h *handlers) handleAdminListUsers(w http.ResponseWriter, r *http.Request)
|
||||
DisplayName: row.DisplayName,
|
||||
IsAdmin: row.IsAdmin,
|
||||
AutoApproveRequests: row.AutoApproveRequests,
|
||||
DebugModeEnabled: row.DebugModeEnabled,
|
||||
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||
}
|
||||
out = append(out, v)
|
||||
@@ -120,6 +122,7 @@ func (h *handlers) handleUpdateUserAdmin(w http.ResponseWriter, r *http.Request)
|
||||
DisplayName: updated.DisplayName,
|
||||
IsAdmin: updated.IsAdmin,
|
||||
AutoApproveRequests: updated.AutoApproveRequests,
|
||||
DebugModeEnabled: updated.DebugModeEnabled,
|
||||
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
@@ -189,6 +192,7 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
DisplayName: user.DisplayName,
|
||||
IsAdmin: user.IsAdmin,
|
||||
AutoApproveRequests: user.AutoApproveRequests,
|
||||
DebugModeEnabled: user.DebugModeEnabled,
|
||||
CreatedAt: user.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
@@ -329,6 +333,7 @@ func (h *handlers) handleAdminAutoApproveToggle(w http.ResponseWriter, r *http.R
|
||||
DisplayName: updated.DisplayName,
|
||||
IsAdmin: updated.IsAdmin,
|
||||
AutoApproveRequests: updated.AutoApproveRequests,
|
||||
DebugModeEnabled: updated.DebugModeEnabled,
|
||||
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
+31
-1
@@ -22,16 +22,19 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
)
|
||||
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
recSettings: recSettings,
|
||||
rng: rng.Float64,
|
||||
lidarrCfg: lidarrCfg,
|
||||
lidarrRequests: lidarrReqs,
|
||||
@@ -40,6 +43,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
playlists: playlistsSvc,
|
||||
coverart: coverEnricher,
|
||||
coverSettings: coverSettings,
|
||||
tagSettings: tagSettings,
|
||||
scanner: scanner,
|
||||
scanCfg: scanCfg,
|
||||
dataDir: dataDir,
|
||||
@@ -135,6 +139,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
// load failures). Admin-only inbox; any user can report.
|
||||
authed.Post("/playback-errors", h.handleReportPlaybackError)
|
||||
|
||||
// Device diagnostics ingest (M9). Any signed-in user can
|
||||
// POST a batch, but events are only stored when the
|
||||
// account's debug_mode_enabled flag is on (handler no-ops
|
||||
// otherwise). Admin views live under /admin/diagnostics.
|
||||
authed.Post("/diagnostics", h.handleReportDiagnostics)
|
||||
|
||||
// Self-hosted in-app update channel (#397). Auth-gated to
|
||||
// prevent anonymous bandwidth abuse on the APK stream;
|
||||
// /apk additionally per-user rate-limited.
|
||||
@@ -181,15 +191,33 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
admin.Delete("/users/{id}", h.handleAdminDeleteUser)
|
||||
admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword)
|
||||
admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle)
|
||||
admin.Put("/users/{id}/debug-mode", h.handleAdminDebugModeToggle)
|
||||
|
||||
// Device diagnostics timeline + device overview (M9).
|
||||
admin.Get("/diagnostics", h.handleListAdminDiagnostics)
|
||||
admin.Get("/diagnostics/devices", h.handleListAdminDiagnosticDevices)
|
||||
|
||||
admin.Get("/cover-sources", h.handleListCoverSources)
|
||||
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
||||
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
||||
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
||||
|
||||
admin.Get("/tag-sources", h.handleListTagSources)
|
||||
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
||||
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||
admin.Post("/tag-sources/research", h.handleResearchTags)
|
||||
|
||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||
|
||||
// Recommendation tuning lab (#1250): scoring-weight
|
||||
// profiles + taste-build knobs, DB-backed, live effect.
|
||||
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||
// Weekly outcome trends + knob-turn markers (#1251).
|
||||
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
|
||||
})
|
||||
|
||||
authed.Get("/playlists", h.handleListPlaylists)
|
||||
@@ -212,6 +240,7 @@ type handlers struct {
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
recSettings *recsettings.Service
|
||||
rng func() float64
|
||||
lidarrCfg *lidarrconfig.Service
|
||||
lidarrRequests *lidarrrequests.Service
|
||||
@@ -220,6 +249,7 @@ type handlers struct {
|
||||
playlists *playlists.Service
|
||||
coverart *coverart.Enricher
|
||||
coverSettings *coverart.SettingsService
|
||||
tagSettings *tags.SettingsService
|
||||
scanner *library.Scanner
|
||||
scanCfg library.RunScanConfig
|
||||
dataDir string
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
)
|
||||
|
||||
@@ -56,11 +57,12 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
dbtest.ResetDB(t, pool)
|
||||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||
recCfg := config.RecommendationConfig{
|
||||
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
||||
SkipPenalty: 1.0, JitterMagnitude: 0.1,
|
||||
ContextWeight: 2.0, SimilarityWeight: 2.0,
|
||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||
}
|
||||
recSettings, err := recsettings.New(context.Background(), pool, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("recsettings: %v", err)
|
||||
}
|
||||
lidarrCfg := lidarrconfig.New(pool)
|
||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
||||
@@ -70,7 +72,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
dataDir := t.TempDir()
|
||||
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
||||
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
||||
return h, pool
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// validDiagnosticKinds is the COARSE category whitelist. Mirrors the
|
||||
// CHECK constraint in migration 0036 — keep both in sync (per the
|
||||
// enum-CHECK-whitelist rule). The finer event discriminator + all
|
||||
// event-specific fields live inside each event's payload, so new event
|
||||
// variants do NOT require a new category here.
|
||||
var validDiagnosticKinds = map[string]struct{}{
|
||||
"connectivity": {},
|
||||
"upnp_sync": {}, // genuinely UPnP/Sonos-specific (drops, resync)
|
||||
"playback": {}, // route + player-state, any output (phone/BT/UPnP)
|
||||
"power": {},
|
||||
"lifecycle": {},
|
||||
"heartbeat": {},
|
||||
"http": {},
|
||||
}
|
||||
|
||||
// maxDiagnosticBatch caps a single ingest call. The client buffers and
|
||||
// flushes in batches of ~100; 500 leaves generous headroom while
|
||||
// bounding a misbehaving client's per-request work.
|
||||
const maxDiagnosticBatch = 500
|
||||
|
||||
// Diagnostics list paging. Larger than the generic parsePaging caps
|
||||
// because the admin timeline is meant to be exported as a slice and
|
||||
// handed off for analysis — a heartbeat-dense window has many rows.
|
||||
const (
|
||||
defaultDiagnosticPageSize = 500
|
||||
maxDiagnosticPageSize = 5000
|
||||
)
|
||||
|
||||
// diagnosticEventIn is one event in a POST /api/diagnostics batch.
|
||||
// OccurredAt is the client device clock as epoch milliseconds (may be
|
||||
// skewed — the server also stamps received_at). Payload is opaque JSON
|
||||
// carrying the event's sub-type + fields.
|
||||
type diagnosticEventIn struct {
|
||||
Kind string `json:"kind"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// reportDiagnosticsRequest is the body of POST /api/diagnostics — one
|
||||
// batch from one device.
|
||||
type reportDiagnosticsRequest struct {
|
||||
ClientID string `json:"client_id"`
|
||||
AppVersion *string `json:"app_version,omitempty"`
|
||||
OsVersion *string `json:"os_version,omitempty"`
|
||||
Events []diagnosticEventIn `json:"events"`
|
||||
}
|
||||
|
||||
// handleReportDiagnostics implements POST /api/diagnostics. Any signed-in
|
||||
// user can call it, but events are only stored when the account's
|
||||
// debug_mode_enabled flag is on; otherwise the call is a 204 no-op so the
|
||||
// client can safely drop the batch (it gates locally on /api/me, this is
|
||||
// the race-safe backstop for "admin just disabled debug").
|
||||
func (h *handlers) handleReportDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !user.DebugModeEnabled {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
var req reportDiagnosticsRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ClientID) == "" {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "client_id required"))
|
||||
return
|
||||
}
|
||||
if len(req.Events) == 0 {
|
||||
writeJSON(w, http.StatusAccepted, map[string]int{"accepted": 0})
|
||||
return
|
||||
}
|
||||
if len(req.Events) > maxDiagnosticBatch {
|
||||
writeErr(w, apierror.BadRequest("batch_too_large", "too many events in one batch"))
|
||||
return
|
||||
}
|
||||
for i := range req.Events {
|
||||
if _, valid := validDiagnosticKinds[req.Events[i].Kind]; !valid {
|
||||
writeErr(w, apierror.BadRequest("invalid_kind", "unknown diagnostic kind"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := pgx.BeginFunc(r.Context(), h.pool, func(tx pgx.Tx) error {
|
||||
q := dbq.New(tx)
|
||||
for i := range req.Events {
|
||||
ev := req.Events[i]
|
||||
payload := ev.Payload
|
||||
if len(payload) == 0 {
|
||||
payload = json.RawMessage("{}")
|
||||
}
|
||||
if _, err := q.InsertDiagnosticEvent(r.Context(), dbq.InsertDiagnosticEventParams{
|
||||
UserID: user.ID,
|
||||
ClientID: req.ClientID,
|
||||
AppVersion: req.AppVersion,
|
||||
OsVersion: req.OsVersion,
|
||||
Kind: ev.Kind,
|
||||
Payload: payload,
|
||||
OccurredAt: pgtype.Timestamptz{Time: time.UnixMilli(ev.OccurredAt).UTC(), Valid: true},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "diagnostics: insert batch failed", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]int{"accepted": len(req.Events)})
|
||||
}
|
||||
|
||||
// adminDiagnosticView is one row in the admin timeline response. Payload
|
||||
// is passed through verbatim as raw JSON so the SPA can render / export
|
||||
// the structured event without the server re-shaping it.
|
||||
type adminDiagnosticView struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
ClientID string `json:"client_id"`
|
||||
AppVersion *string `json:"app_version,omitempty"`
|
||||
OsVersion *string `json:"os_version,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
ReceivedAt string `json:"received_at"`
|
||||
}
|
||||
|
||||
// handleListAdminDiagnostics implements GET /api/admin/diagnostics with
|
||||
// optional filters user_id, client_id, kind, from, to (RFC3339 or epoch
|
||||
// millis) plus limit/offset. Newest-first; the SPA reverses for a
|
||||
// chronological timeline + export.
|
||||
func (h *handlers) handleListAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
params, ok := h.buildDiagnosticsFilter(w, q)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := dbq.New(h.pool).ListAdminDiagnostics(r.Context(), params)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: list diagnostics failed", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
out := make([]adminDiagnosticView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, adminDiagnosticView{
|
||||
ID: uuidToString(row.ID),
|
||||
UserID: uuidToString(row.UserID),
|
||||
Username: row.Username,
|
||||
ClientID: row.ClientID,
|
||||
AppVersion: row.AppVersion,
|
||||
OsVersion: row.OsVersion,
|
||||
Kind: row.Kind,
|
||||
Payload: json.RawMessage(row.Payload),
|
||||
OccurredAt: formatTimestamp(row.OccurredAt),
|
||||
ReceivedAt: formatTimestamp(row.ReceivedAt),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// buildDiagnosticsFilter parses the query string into a query param
|
||||
// struct, writing a 400 and returning ok=false on a malformed time.
|
||||
func (h *handlers) buildDiagnosticsFilter(
|
||||
w http.ResponseWriter, q map[string][]string,
|
||||
) (dbq.ListAdminDiagnosticsParams, bool) {
|
||||
get := func(k string) string {
|
||||
if v, present := q[k]; present && len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
limit, offset, err := parseDiagPaging(get("limit"), get("offset"))
|
||||
if err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid limit or offset"))
|
||||
return dbq.ListAdminDiagnosticsParams{}, false
|
||||
}
|
||||
from, okFrom := parseDiagTime(get("from"))
|
||||
to, okTo := parseDiagTime(get("to"))
|
||||
if !okFrom || !okTo {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid from/to timestamp"))
|
||||
return dbq.ListAdminDiagnosticsParams{}, false
|
||||
}
|
||||
params := dbq.ListAdminDiagnosticsParams{
|
||||
ClientID: optionalQuery(get("client_id")),
|
||||
Kind: optionalQuery(get("kind")),
|
||||
FromTs: from,
|
||||
ToTs: to,
|
||||
Off: int32(offset),
|
||||
Lim: int32(limit),
|
||||
}
|
||||
if uid, valid := parseUUID(get("user_id")); valid {
|
||||
params.UserID = uid
|
||||
}
|
||||
return params, true
|
||||
}
|
||||
|
||||
// adminDiagnosticDeviceView is one device in the "who is reporting"
|
||||
// overview / device filter dropdown.
|
||||
type adminDiagnosticDeviceView struct {
|
||||
ClientID string `json:"client_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
AppVersion string `json:"app_version"`
|
||||
OsVersion string `json:"os_version"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
EventCount int64 `json:"event_count"`
|
||||
}
|
||||
|
||||
// handleListAdminDiagnosticDevices implements GET
|
||||
// /api/admin/diagnostics/devices?user_id=. Lists distinct reporting
|
||||
// devices so the admin UI can populate its device filter.
|
||||
func (h *handlers) handleListAdminDiagnosticDevices(w http.ResponseWriter, r *http.Request) {
|
||||
var userID pgtype.UUID
|
||||
if uid, valid := parseUUID(r.URL.Query().Get("user_id")); valid {
|
||||
userID = uid
|
||||
}
|
||||
rows, err := dbq.New(h.pool).ListDiagnosticDevices(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: list diagnostic devices failed", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
out := make([]adminDiagnosticDeviceView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, adminDiagnosticDeviceView{
|
||||
ClientID: row.ClientID,
|
||||
UserID: uuidToString(row.UserID),
|
||||
Username: row.Username,
|
||||
AppVersion: row.AppVersion,
|
||||
OsVersion: row.OsVersion,
|
||||
LastSeen: formatTimestamp(row.LastSeen),
|
||||
EventCount: row.EventCount,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// adminDebugModeReq is the body of PUT /api/admin/users/{id}/debug-mode.
|
||||
type adminDebugModeReq struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// handleAdminDebugModeToggle implements PUT
|
||||
// /api/admin/users/{id}/debug-mode — flip an account's diagnostics
|
||||
// opt-in remotely while a bug is live. Echoes the updated user.
|
||||
func (h *handlers) handleAdminDebugModeToggle(w http.ResponseWriter, r *http.Request) {
|
||||
targetID, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req adminDebugModeReq
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
updated, err := dbq.New(h.pool).SetDebugMode(r.Context(), dbq.SetDebugModeParams{
|
||||
ID: targetID,
|
||||
DebugModeEnabled: req.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: set debug mode failed", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
h.logger.Info("admin: debug mode toggled",
|
||||
"target_user", uuidToString(targetID), "enabled", req.Enabled)
|
||||
writeJSON(w, http.StatusOK, adminUserView{
|
||||
ID: uuidToString(updated.ID),
|
||||
Username: updated.Username,
|
||||
DisplayName: updated.DisplayName,
|
||||
IsAdmin: updated.IsAdmin,
|
||||
AutoApproveRequests: updated.AutoApproveRequests,
|
||||
DebugModeEnabled: updated.DebugModeEnabled,
|
||||
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// parseDiagPaging reads limit/offset with diagnostics-specific caps.
|
||||
// Blank → defaults; out-of-range clamps; non-numeric → error.
|
||||
func parseDiagPaging(rawLimit, rawOffset string) (limit, offset int, err error) {
|
||||
limit = defaultDiagnosticPageSize
|
||||
if s := strings.TrimSpace(rawLimit); s != "" {
|
||||
n, perr := strconv.Atoi(s)
|
||||
if perr != nil {
|
||||
return 0, 0, perr
|
||||
}
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > maxDiagnosticPageSize {
|
||||
n = maxDiagnosticPageSize
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
if s := strings.TrimSpace(rawOffset); s != "" {
|
||||
n, perr := strconv.Atoi(s)
|
||||
if perr != nil {
|
||||
return 0, 0, perr
|
||||
}
|
||||
if n > 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
return limit, offset, nil
|
||||
}
|
||||
|
||||
// optionalQuery returns nil for an absent/blank query value so the sqlc
|
||||
// NULL-or-equals filter treats it as "no filter."
|
||||
func optionalQuery(raw string) *string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
return &raw
|
||||
}
|
||||
|
||||
// parseDiagTime accepts RFC3339 or epoch-millis. Empty = absent (NULL
|
||||
// filter, ok=true). Returns ok=false only on a non-empty unparseable value.
|
||||
func parseDiagTime(raw string) (pgtype.Timestamptz, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return pgtype.Timestamptz{}, true
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return pgtype.Timestamptz{Time: t.UTC(), Valid: true}, true
|
||||
}
|
||||
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return pgtype.Timestamptz{Time: time.UnixMilli(ms).UTC(), Valid: true}, true
|
||||
}
|
||||
return pgtype.Timestamptz{}, false
|
||||
}
|
||||
+54
-3
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -24,6 +25,33 @@ type eventRequest struct {
|
||||
// / "discover" feed the system-playlist rotation dedup (#415);
|
||||
// absent / "" for library, user-playlist, radio, Subsonic.
|
||||
Source *string `json:"source"`
|
||||
// DeviceClass is the client's self-reported device class (#1551),
|
||||
// normalized + stored per play to condition radio on the current device.
|
||||
DeviceClass *string `json:"device_class"`
|
||||
}
|
||||
|
||||
// knownDeviceClasses whitelists the device classes the context-affinity facet
|
||||
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
|
||||
// "other", and empty/absent to "" (stored NULL → excluded from the device
|
||||
// dimension). Kept permissive (no DB CHECK) so a new client class is one
|
||||
// whitelist entry, not a migration.
|
||||
var knownDeviceClasses = map[string]bool{
|
||||
"mobile": true, "tablet": true, "desktop": true,
|
||||
"web": true, "tv": true, "watch": true,
|
||||
}
|
||||
|
||||
func normalizeDeviceClass(dc *string) string {
|
||||
if dc == nil {
|
||||
return ""
|
||||
}
|
||||
s := strings.ToLower(strings.TrimSpace(*dc))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if knownDeviceClasses[s] {
|
||||
return s
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
type playStartedResponse struct {
|
||||
@@ -31,6 +59,26 @@ type playStartedResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// eventFutureSkew is the tolerated client-clock drift into the future
|
||||
// before a timestamp is treated as bogus and replaced with now.
|
||||
const eventFutureSkew = 5 * time.Minute
|
||||
|
||||
// clampEventTime bounds a client-supplied event timestamp to sanity:
|
||||
// no earlier than the account's creation (offline replays can
|
||||
// legitimately be days old, but no play can predate the user) and no
|
||||
// later than now + a small skew allowance. Unbounded client clocks
|
||||
// previously let a skewed device write arbitrarily old plays, which
|
||||
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
|
||||
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
|
||||
if at.Before(userCreatedAt) {
|
||||
return userCreatedAt
|
||||
}
|
||||
if at.After(now.Add(eventFutureSkew)) {
|
||||
return now
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
type okResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
@@ -52,7 +100,7 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
|
||||
return
|
||||
}
|
||||
at = parsed
|
||||
at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC())
|
||||
}
|
||||
clientID := ""
|
||||
if req.ClientID != nil {
|
||||
@@ -94,7 +142,9 @@ func (h *handlers) handleEventPlayStarted(
|
||||
if req.Source != nil {
|
||||
source = *req.Source
|
||||
}
|
||||
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
|
||||
deviceClass := normalizeDeviceClass(req.DeviceClass)
|
||||
res, err := h.events.RecordPlayStartedWithSource(
|
||||
r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
|
||||
if err != nil {
|
||||
h.logger.Error("api: events: play_started", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||
@@ -139,7 +189,8 @@ func (h *handlers) handleEventPlayOffline(
|
||||
source = *req.Source
|
||||
}
|
||||
if err := h.events.RecordOfflinePlay(
|
||||
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
|
||||
r.Context(), user.ID, trackID, clientID, source,
|
||||
normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
|
||||
); err != nil {
|
||||
h.logger.Error("api: events: play_offline", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||
|
||||
@@ -88,6 +88,21 @@ func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.
|
||||
})
|
||||
}
|
||||
|
||||
// publishSystemRebuilt notifies the owner's clients that their system
|
||||
// playlists (and You-might-like rows) were regenerated, so they invalidate
|
||||
// the home / system-playlist providers and a stale active queue can re-pull.
|
||||
// Mirrors the daily scheduler's event; fired here from the manual refresh.
|
||||
func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
|
||||
if h.eventbus == nil {
|
||||
return
|
||||
}
|
||||
h.eventbus.Publish(eventbus.Event{
|
||||
Kind: "playlist.system_rebuilt",
|
||||
UserID: uuidToString(userID),
|
||||
Data: map[string]any{},
|
||||
})
|
||||
}
|
||||
|
||||
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
|
||||
// the request's original requester so their /requests page reflects the
|
||||
// new state without manual refresh. Admin actors (approve / reject) still
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
@@ -35,6 +35,10 @@ type meProfileResp struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Email *string `json:"email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
// DebugModeEnabled is the account's diagnostics opt-in. The client
|
||||
// reads it here to decide whether to run the on-device diagnostics
|
||||
// reporter (M9). Admin-set; the client also has a local OFF switch.
|
||||
DebugModeEnabled bool `json:"debug_mode_enabled"`
|
||||
}
|
||||
|
||||
func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -125,5 +129,6 @@ func profileViewFromUser(u dbq.User) meProfileResp {
|
||||
DisplayName: u.DisplayName,
|
||||
Email: u.Email,
|
||||
IsAdmin: u.IsAdmin,
|
||||
DebugModeEnabled: u.DebugModeEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user