Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d145fee35d | |||
| 7628330f72 | |||
| 09102dc615 | |||
| 611715154b | |||
| 392454b249 | |||
| cdfc79e6ab | |||
| 79f2d79a2e | |||
| 96b15b75e6 | |||
| 23a82fb38d | |||
| 782f152d37 | |||
| bffa5b28bd | |||
| 8a58f07237 | |||
| 4d42e298dd | |||
| 4ed831d9c3 | |||
| 0de2437689 | |||
| f4f4df7708 | |||
| e932ab438c | |||
| d05264ff80 | |||
| a23e2e36ca | |||
| 5a80a1e460 | |||
| 16f76ea707 | |||
| d4cc177db4 | |||
| 27766ae063 | |||
| 335d782215 | |||
| 2a8de82a17 | |||
| 096a3c0b15 | |||
| a251dce7e3 | |||
| 97e0e88483 | |||
| 938dae7163 | |||
| ad37937949 | |||
| 93365cb555 | |||
| 2c5c477a0d | |||
| eeabdf1f2c | |||
| f7278f2417 | |||
| 9a166f9032 | |||
| b3d6785543 | |||
| 773275e916 | |||
| 066616e196 |
@@ -2,8 +2,8 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to main → :main and :latest (no APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (APK bundled)
|
||||
# push to main → :main and :latest (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
#
|
||||
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
||||
@@ -20,6 +20,14 @@ name: release
|
||||
# happens in the same android-release job, so the Release-page download
|
||||
# link and the in-image bundled APK are both populated atomically.
|
||||
#
|
||||
# :latest always carries an APK. Because every main push also moves
|
||||
# :latest (not just tags), a main build with no APK would silently strip
|
||||
# the in-app update channel off :latest until the next release. So on
|
||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||
# and reconstructs its exact versionName (tag + commit-count, the same
|
||||
# formula android-release bakes in) for the version sidecar — no rebuild,
|
||||
# just rebundle. Tag builds keep bundling their own freshly-built APK.
|
||||
#
|
||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||
# lives in android.yml and runs independently on every push.
|
||||
|
||||
@@ -181,6 +189,12 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags so non-tag :latest builds can resolve the
|
||||
# latest release tag's commit count and reconstruct the bundled
|
||||
# APK's exact versionName (see "Bundle latest release APK" below).
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
@@ -221,9 +235,8 @@ jobs:
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Download signed APK artifact
|
||||
# Tag pushes only — android-release just produced this. Main
|
||||
# pushes skip and the image ships with empty client/ (the
|
||||
# /api/client/version endpoint then returns 404 by design).
|
||||
# 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
|
||||
with:
|
||||
@@ -247,6 +260,42 @@ jobs:
|
||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Bundle latest release APK (non-tag :latest builds)
|
||||
# Main pushes don't build an APK, but they DO move :latest — so
|
||||
# without this the in-app update channel would vanish from :latest
|
||||
# until the next tag. Pull the most-recent release's signed APK and
|
||||
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
|
||||
# TAG) — identical to android-release's formula) so the version
|
||||
# sidecar the server hands clients matches the installed build.
|
||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||
# version — if no release / APK asset / tag-count can be resolved.
|
||||
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
||||
if [ -z "${REL_JSON}" ]; then
|
||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
|
||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
|
||||
if [ -z "${COUNT}" ]; then
|
||||
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
||||
fi
|
||||
VERSION_NAME="${TAG#v}.${COUNT}"
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||
echo "${VERSION_NAME}" > client/minstrel.apk.version
|
||||
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
|
||||
ls -lh client/
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ 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
|
||||
@@ -155,6 +157,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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+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,
|
||||
)
|
||||
+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,
|
||||
|
||||
@@ -55,6 +55,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
|
||||
@@ -77,6 +78,7 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
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.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
@@ -95,6 +97,7 @@ import kotlinx.coroutines.flow.combine
|
||||
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 javax.inject.Inject
|
||||
|
||||
@@ -139,6 +142,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() {
|
||||
|
||||
@@ -165,8 +169,31 @@ class HomeViewModel @Inject constructor(
|
||||
/** Transient snackbar messages from offline-pool taps. */
|
||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// 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.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { refresh() }
|
||||
}
|
||||
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
||||
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
||||
// and You-might-like rows reflect the new snapshot without a manual
|
||||
// reload. Mirrors the web SSE consumer.
|
||||
viewModelScope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind == "playlist.system_rebuilt" }
|
||||
.collect { refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,7 +305,14 @@ class HomeViewModel @Inject constructor(
|
||||
* actual completion before hiding the indicator.
|
||||
*/
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
val home = launch { runCatching { homeRepository.refreshIndex() } }
|
||||
refreshError.value = null
|
||||
val home = launch {
|
||||
// /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.
|
||||
runCatching { homeRepository.refreshIndex() }
|
||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||
}
|
||||
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||
val status = launch {
|
||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||
@@ -325,9 +359,17 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,9 +420,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.refresh() },
|
||||
)
|
||||
is UiState.Success -> HomeSuccessContent(
|
||||
sections = s.data,
|
||||
|
||||
@@ -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 {
|
||||
UiState.Success(LibraryData(artists, albums))
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -351,7 +361,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 +382,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 +484,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(
|
||||
@@ -763,6 +841,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+26
-9
@@ -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,14 +125,15 @@ 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 {
|
||||
|
||||
@@ -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,44 +24,63 @@ 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
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.TriangleAlert,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = actionColors.primary,
|
||||
contentColor = actionColors.onAction,
|
||||
),
|
||||
) {
|
||||
Text(retryLabel)
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillParentMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.TriangleAlert,
|
||||
contentDescription = null,
|
||||
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 = if (title != null) 8.dp else 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = actionColors.primary,
|
||||
contentColor = actionColors.onAction,
|
||||
),
|
||||
) {
|
||||
Text(retryLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
@Composable
|
||||
fun ServerImage(
|
||||
@@ -22,12 +32,26 @@ 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)
|
||||
}
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = resolved,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = contentScale,
|
||||
onState = { state = it },
|
||||
)
|
||||
if (state is AsyncImagePainter.State.Loading ||
|
||||
state is AsyncImagePainter.State.Error
|
||||
) {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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),
|
||||
)
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -242,7 +242,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)
|
||||
}
|
||||
|
||||
+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 |
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -135,6 +135,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,6 +187,11 @@ 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
@@ -120,10 +124,11 @@ func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func profileViewFromUser(u dbq.User) meProfileResp {
|
||||
return meProfileResp{
|
||||
ID: uuidToString(u.ID),
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Email: u.Email,
|
||||
IsAdmin: u.IsAdmin,
|
||||
ID: uuidToString(u.ID),
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Email: u.Email,
|
||||
IsAdmin: u.IsAdmin,
|
||||
DebugModeEnabled: u.DebugModeEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Re
|
||||
writeErr(w, apierror.InternalMsg("build failed", err))
|
||||
return
|
||||
}
|
||||
// #968: announce the rebuild so the user's other clients refresh.
|
||||
h.publishSystemRebuilt(user.ID)
|
||||
q := dbq.New(h.pool)
|
||||
v := kind
|
||||
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||
|
||||
@@ -135,6 +135,11 @@ func Default() Config {
|
||||
RadioSizeMax: 200,
|
||||
},
|
||||
Library: LibraryConfig{
|
||||
// On by default so a fresh install populates its library on
|
||||
// first boot without the operator hunting for a "scan now"
|
||||
// button. Scans are incremental (unchanged files skipped by
|
||||
// mtime), so every later restart is just a cheap directory walk.
|
||||
ScanOnStartup: true,
|
||||
CoverArtFromMBCAA: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -97,7 +97,8 @@ func TestEnvOverrides(t *testing.T) {
|
||||
func TestLibraryEnvOverrides(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true")
|
||||
// Default is true; set false so the override path is actually exercised.
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "false")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
@@ -112,8 +113,8 @@ func TestLibraryEnvOverrides(t *testing.T) {
|
||||
t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p)
|
||||
}
|
||||
}
|
||||
if !cfg.Library.ScanOnStartup {
|
||||
t.Error("scan_on_startup = false, want true")
|
||||
if cfg.Library.ScanOnStartup {
|
||||
t.Error("scan_on_startup = true, want false (env override of the true default)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: diagnostics.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const gcPruneDiagnostics = `-- name: GcPruneDiagnostics :execrows
|
||||
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days'
|
||||
`
|
||||
|
||||
// Retention sweep for the gc worker. Deletes diagnostic_events older
|
||||
// than 30 days by server clock (received_at) so a skewed client clock
|
||||
// can't keep rows alive. Matches the other gc lifecycle sweeps'
|
||||
// hardcoded-threshold style; diagnostics are debug telemetry, not user
|
||||
// data, and 30 days is ample to investigate a live incident after the
|
||||
// fact. Returns the affected row count for the sweep log line.
|
||||
func (q *Queries) GcPruneDiagnostics(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, gcPruneDiagnostics)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const insertDiagnosticEvent = `-- name: InsertDiagnosticEvent :one
|
||||
INSERT INTO diagnostic_events (
|
||||
user_id, client_id, app_version, os_version, kind, payload, occurred_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, received_at
|
||||
`
|
||||
|
||||
type InsertDiagnosticEventParams struct {
|
||||
UserID pgtype.UUID
|
||||
ClientID string
|
||||
AppVersion *string
|
||||
OsVersion *string
|
||||
Kind string
|
||||
Payload []byte
|
||||
OccurredAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type InsertDiagnosticEventRow struct {
|
||||
ID pgtype.UUID
|
||||
ReceivedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// Records one client-reported diagnostic event. The writer validates the
|
||||
// `kind` category against its whitelist before this runs; the CHECK
|
||||
// constraint is the belt-and-braces guard. payload carries the finer
|
||||
// event discriminator + all event-specific fields. Returns received_at
|
||||
// so the caller can confirm ingest.
|
||||
func (q *Queries) InsertDiagnosticEvent(ctx context.Context, arg InsertDiagnosticEventParams) (InsertDiagnosticEventRow, error) {
|
||||
row := q.db.QueryRow(ctx, insertDiagnosticEvent,
|
||||
arg.UserID,
|
||||
arg.ClientID,
|
||||
arg.AppVersion,
|
||||
arg.OsVersion,
|
||||
arg.Kind,
|
||||
arg.Payload,
|
||||
arg.OccurredAt,
|
||||
)
|
||||
var i InsertDiagnosticEventRow
|
||||
err := row.Scan(&i.ID, &i.ReceivedAt)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAdminDiagnostics = `-- name: ListAdminDiagnostics :many
|
||||
SELECT
|
||||
de.id AS id,
|
||||
de.user_id AS user_id,
|
||||
u.username AS username,
|
||||
de.client_id AS client_id,
|
||||
de.app_version AS app_version,
|
||||
de.os_version AS os_version,
|
||||
de.kind AS kind,
|
||||
de.payload AS payload,
|
||||
de.occurred_at AS occurred_at,
|
||||
de.received_at AS received_at
|
||||
FROM diagnostic_events de
|
||||
JOIN users u ON u.id = de.user_id
|
||||
WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid)
|
||||
AND ($2::text IS NULL OR de.client_id = $2::text)
|
||||
AND ($3::text IS NULL OR de.kind = $3::text)
|
||||
AND ($4::timestamptz IS NULL
|
||||
OR de.occurred_at >= $4::timestamptz)
|
||||
AND ($5::timestamptz IS NULL
|
||||
OR de.occurred_at <= $5::timestamptz)
|
||||
ORDER BY de.occurred_at DESC
|
||||
LIMIT $7::int OFFSET $6::int
|
||||
`
|
||||
|
||||
type ListAdminDiagnosticsParams struct {
|
||||
UserID pgtype.UUID
|
||||
ClientID *string
|
||||
Kind *string
|
||||
FromTs pgtype.Timestamptz
|
||||
ToTs pgtype.Timestamptz
|
||||
Off int32
|
||||
Lim int32
|
||||
}
|
||||
|
||||
type ListAdminDiagnosticsRow struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Username string
|
||||
ClientID string
|
||||
AppVersion *string
|
||||
OsVersion *string
|
||||
Kind string
|
||||
Payload []byte
|
||||
OccurredAt pgtype.Timestamptz
|
||||
ReceivedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// Admin timeline query. All filters are optional (NULL = no filter):
|
||||
// account (user_id), device (client_id), category (kind), and the
|
||||
// occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for
|
||||
// a chronological timeline / export. Joined with users for the username
|
||||
// so the view renders without a second round-trip.
|
||||
func (q *Queries) ListAdminDiagnostics(ctx context.Context, arg ListAdminDiagnosticsParams) ([]ListAdminDiagnosticsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAdminDiagnostics,
|
||||
arg.UserID,
|
||||
arg.ClientID,
|
||||
arg.Kind,
|
||||
arg.FromTs,
|
||||
arg.ToTs,
|
||||
arg.Off,
|
||||
arg.Lim,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListAdminDiagnosticsRow
|
||||
for rows.Next() {
|
||||
var i ListAdminDiagnosticsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Username,
|
||||
&i.ClientID,
|
||||
&i.AppVersion,
|
||||
&i.OsVersion,
|
||||
&i.Kind,
|
||||
&i.Payload,
|
||||
&i.OccurredAt,
|
||||
&i.ReceivedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDiagnosticDevices = `-- name: ListDiagnosticDevices :many
|
||||
SELECT
|
||||
de.client_id AS client_id,
|
||||
de.user_id AS user_id,
|
||||
u.username AS username,
|
||||
max(de.app_version)::text AS app_version,
|
||||
max(de.os_version)::text AS os_version,
|
||||
max(de.occurred_at)::timestamptz AS last_seen,
|
||||
count(*) AS event_count
|
||||
FROM diagnostic_events de
|
||||
JOIN users u ON u.id = de.user_id
|
||||
WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid)
|
||||
GROUP BY de.client_id, de.user_id, u.username
|
||||
ORDER BY last_seen DESC
|
||||
`
|
||||
|
||||
type ListDiagnosticDevicesRow struct {
|
||||
ClientID string
|
||||
UserID pgtype.UUID
|
||||
Username string
|
||||
AppVersion string
|
||||
OsVersion string
|
||||
LastSeen pgtype.Timestamptz
|
||||
EventCount int64
|
||||
}
|
||||
|
||||
// Distinct devices that have reported, for the admin device filter +
|
||||
// "who is currently reporting" overview. Optionally scoped to one account.
|
||||
func (q *Queries) ListDiagnosticDevices(ctx context.Context, userID pgtype.UUID) ([]ListDiagnosticDevicesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listDiagnosticDevices, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListDiagnosticDevicesRow
|
||||
for rows.Next() {
|
||||
var i ListDiagnosticDevicesRow
|
||||
if err := rows.Scan(
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.Username,
|
||||
&i.AppVersion,
|
||||
&i.OsVersion,
|
||||
&i.LastSeen,
|
||||
&i.EventCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -265,6 +265,18 @@ type CoverArtSourcesMetum struct {
|
||||
LastRegisteredProvidersHash string
|
||||
}
|
||||
|
||||
type DiagnosticEvent struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
ClientID string
|
||||
AppVersion *string
|
||||
OsVersion *string
|
||||
Kind string
|
||||
Payload []byte
|
||||
OccurredAt pgtype.Timestamptz
|
||||
ReceivedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type GeneralLike struct {
|
||||
UserID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
@@ -552,6 +564,7 @@ type User struct {
|
||||
Email *string
|
||||
Timezone string
|
||||
TimezoneUpdatedAt pgtype.Timestamptz
|
||||
DebugModeEnabled bool
|
||||
}
|
||||
|
||||
type UserInvite struct {
|
||||
|
||||
@@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
@@ -89,6 +89,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -96,7 +97,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
const createUserAdmin = `-- name: CreateUserAdmin :one
|
||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||
`
|
||||
|
||||
type CreateUserAdminParams struct {
|
||||
@@ -134,6 +135,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -145,7 +147,7 @@ VALUES (
|
||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||
$4
|
||||
)
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||
`
|
||||
|
||||
type CreateUserFirstAdminRaceParams struct {
|
||||
@@ -191,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -237,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
|
||||
}
|
||||
|
||||
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE api_token = $1
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
|
||||
@@ -258,12 +261,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE lower(email) = lower($1)
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE lower(email) = lower($1)
|
||||
`
|
||||
|
||||
// Used by forgot-password lookup. Lowercase comparison both sides
|
||||
@@ -287,12 +291,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE id = $1
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
|
||||
@@ -313,12 +318,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE username = $1
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE username = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||
@@ -339,6 +345,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -382,7 +389,8 @@ func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiv
|
||||
}
|
||||
|
||||
const listUsers = `-- name: ListUsers :many
|
||||
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
|
||||
SELECT id, username, display_name, is_admin, auto_approve_requests,
|
||||
debug_mode_enabled, created_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
@@ -393,6 +401,7 @@ type ListUsersRow struct {
|
||||
DisplayName *string
|
||||
IsAdmin bool
|
||||
AutoApproveRequests bool
|
||||
DebugModeEnabled bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
@@ -412,6 +421,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
||||
&i.DisplayName,
|
||||
&i.IsAdmin,
|
||||
&i.AutoApproveRequests,
|
||||
&i.DebugModeEnabled,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -426,7 +436,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
||||
|
||||
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
||||
UPDATE users SET api_token = $2 WHERE id = $1
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||
`
|
||||
|
||||
type RegenerateApiTokenParams struct {
|
||||
@@ -454,6 +464,7 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -478,6 +489,44 @@ func (q *Queries) ResetUserPassword(ctx context.Context, arg ResetUserPasswordPa
|
||||
return err
|
||||
}
|
||||
|
||||
const setDebugMode = `-- name: SetDebugMode :one
|
||||
UPDATE users
|
||||
SET debug_mode_enabled = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||
`
|
||||
|
||||
type SetDebugModeParams struct {
|
||||
ID pgtype.UUID
|
||||
DebugModeEnabled bool
|
||||
}
|
||||
|
||||
// Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
|
||||
// from /admin (flip remotely while a bug is live) or self-driven OFF from
|
||||
// the client. Returns the updated row so the handler can echo it.
|
||||
func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, setDebugMode, arg.ID, arg.DebugModeEnabled)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
&i.ListenbrainzToken,
|
||||
&i.ListenbrainzEnabled,
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec
|
||||
UPDATE users
|
||||
SET listenbrainz_enabled = $2
|
||||
@@ -531,7 +580,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
|
||||
UPDATE users
|
||||
SET is_admin = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||
`
|
||||
|
||||
type UpdateUserAdminParams struct {
|
||||
@@ -559,6 +608,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -567,7 +617,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
|
||||
UPDATE users
|
||||
SET auto_approve_requests = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||
`
|
||||
|
||||
type UpdateUserAutoApproveParams struct {
|
||||
@@ -594,6 +644,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -603,7 +654,7 @@ UPDATE users
|
||||
SET display_name = $2,
|
||||
email = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||
`
|
||||
|
||||
type UpdateUserProfileParams struct {
|
||||
@@ -632,6 +683,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS debug_mode_enabled;
|
||||
DROP TABLE IF EXISTS diagnostic_events;
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Device diagnostics / debug-reporting subsystem (M9).
|
||||
--
|
||||
-- When an account has users.debug_mode_enabled = true, that account's
|
||||
-- client(s) stream a timeseries of device state here: connectivity +
|
||||
-- server-health transitions (roaming / poor-data recovery), UPnP/Sonos
|
||||
-- sync events (locked-phone desync), and power/lifecycle state (Doze /
|
||||
-- battery-optimization, the prime suspect for the desync). The admin
|
||||
-- /admin/diagnostics view filters by account + device + time window and
|
||||
-- exports the slice as JSON for offline analysis.
|
||||
--
|
||||
-- `kind` is a COARSE category gated by a CHECK whitelist; the finer
|
||||
-- event discriminator + all event-specific fields live in the `payload`
|
||||
-- jsonb. This keeps the enum small and stable so new event variants
|
||||
-- don't require a migration (per the enum-CHECK-whitelist rule, only a
|
||||
-- new *category* would). If you add a category here, mirror it in the
|
||||
-- handler whitelist in internal/api/diagnostics.go.
|
||||
--
|
||||
-- Two timestamps on purpose: occurred_at is the client device clock
|
||||
-- (when the event happened on the phone — may be skewed/wrong, e.g. in
|
||||
-- a dead zone), received_at is the server clock at ingest. Retention
|
||||
-- pruning is keyed on received_at so a bad client clock can't keep rows
|
||||
-- alive forever.
|
||||
|
||||
CREATE TABLE diagnostic_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL,
|
||||
app_version text,
|
||||
os_version text,
|
||||
kind text NOT NULL,
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT diagnostic_events_kind_check
|
||||
CHECK (kind IN (
|
||||
'connectivity', 'upnp_sync', 'power', 'lifecycle',
|
||||
'heartbeat', 'http'
|
||||
))
|
||||
);
|
||||
|
||||
-- Admin reads filter by account then device, ordered newest-first.
|
||||
CREATE INDEX idx_diagnostic_events_user
|
||||
ON diagnostic_events (user_id, occurred_at DESC);
|
||||
CREATE INDEX idx_diagnostic_events_client
|
||||
ON diagnostic_events (client_id, occurred_at DESC);
|
||||
-- Retention pruner deletes by server-clock age.
|
||||
CREATE INDEX idx_diagnostic_events_received
|
||||
ON diagnostic_events (received_at);
|
||||
|
||||
-- Per-account opt-in for diagnostics reporting. Admin-set primarily
|
||||
-- (flip remotely while a bug is live); the client also exposes a local
|
||||
-- OFF switch. Default false: no account reports until explicitly enabled.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS debug_mode_enabled boolean NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Drop rows using the new value first so they don't violate the restored
|
||||
-- (narrower) constraint, then revert the whitelist.
|
||||
DELETE FROM diagnostic_events WHERE kind = 'playback';
|
||||
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
|
||||
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
|
||||
CHECK (kind IN (
|
||||
'connectivity', 'upnp_sync', 'power', 'lifecycle',
|
||||
'heartbeat', 'http'
|
||||
));
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Add 'playback' to the diagnostic_events kind whitelist (M9 follow-up).
|
||||
--
|
||||
-- `route` and `player_state` events fire for EVERY output route (phone
|
||||
-- speaker, Bluetooth, UPnP/Sonos), so bucketing them under 'upnp_sync'
|
||||
-- was misleading — an operator on Bluetooth earbuds saw "upnp_sync"
|
||||
-- rows. 'upnp_sync' now means genuinely UPnP/Sonos-specific signal
|
||||
-- (drops, and future resync/desync); general playback + route telemetry
|
||||
-- moves to the new 'playback' kind.
|
||||
--
|
||||
-- Per the enum-CHECK-whitelist rule, the new value lands by dropping and
|
||||
-- re-adding the constraint in the same change. Existing rows tagged
|
||||
-- 'upnp_sync' stay as-is (debug telemetry on a 30-day retention).
|
||||
|
||||
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
|
||||
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
|
||||
CHECK (kind IN (
|
||||
'connectivity', 'upnp_sync', 'power', 'lifecycle',
|
||||
'heartbeat', 'http', 'playback'
|
||||
));
|
||||
@@ -0,0 +1,66 @@
|
||||
-- name: InsertDiagnosticEvent :one
|
||||
-- Records one client-reported diagnostic event. The writer validates the
|
||||
-- `kind` category against its whitelist before this runs; the CHECK
|
||||
-- constraint is the belt-and-braces guard. payload carries the finer
|
||||
-- event discriminator + all event-specific fields. Returns received_at
|
||||
-- so the caller can confirm ingest.
|
||||
INSERT INTO diagnostic_events (
|
||||
user_id, client_id, app_version, os_version, kind, payload, occurred_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, received_at;
|
||||
|
||||
-- name: ListAdminDiagnostics :many
|
||||
-- Admin timeline query. All filters are optional (NULL = no filter):
|
||||
-- account (user_id), device (client_id), category (kind), and the
|
||||
-- occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for
|
||||
-- a chronological timeline / export. Joined with users for the username
|
||||
-- so the view renders without a second round-trip.
|
||||
SELECT
|
||||
de.id AS id,
|
||||
de.user_id AS user_id,
|
||||
u.username AS username,
|
||||
de.client_id AS client_id,
|
||||
de.app_version AS app_version,
|
||||
de.os_version AS os_version,
|
||||
de.kind AS kind,
|
||||
de.payload AS payload,
|
||||
de.occurred_at AS occurred_at,
|
||||
de.received_at AS received_at
|
||||
FROM diagnostic_events de
|
||||
JOIN users u ON u.id = de.user_id
|
||||
WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid)
|
||||
AND (sqlc.narg(client_id)::text IS NULL OR de.client_id = sqlc.narg(client_id)::text)
|
||||
AND (sqlc.narg(kind)::text IS NULL OR de.kind = sqlc.narg(kind)::text)
|
||||
AND (sqlc.narg(from_ts)::timestamptz IS NULL
|
||||
OR de.occurred_at >= sqlc.narg(from_ts)::timestamptz)
|
||||
AND (sqlc.narg(to_ts)::timestamptz IS NULL
|
||||
OR de.occurred_at <= sqlc.narg(to_ts)::timestamptz)
|
||||
ORDER BY de.occurred_at DESC
|
||||
LIMIT sqlc.arg(lim)::int OFFSET sqlc.arg(off)::int;
|
||||
|
||||
-- name: ListDiagnosticDevices :many
|
||||
-- Distinct devices that have reported, for the admin device filter +
|
||||
-- "who is currently reporting" overview. Optionally scoped to one account.
|
||||
SELECT
|
||||
de.client_id AS client_id,
|
||||
de.user_id AS user_id,
|
||||
u.username AS username,
|
||||
max(de.app_version)::text AS app_version,
|
||||
max(de.os_version)::text AS os_version,
|
||||
max(de.occurred_at)::timestamptz AS last_seen,
|
||||
count(*) AS event_count
|
||||
FROM diagnostic_events de
|
||||
JOIN users u ON u.id = de.user_id
|
||||
WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid)
|
||||
GROUP BY de.client_id, de.user_id, u.username
|
||||
ORDER BY last_seen DESC;
|
||||
|
||||
-- name: GcPruneDiagnostics :execrows
|
||||
-- Retention sweep for the gc worker. Deletes diagnostic_events older
|
||||
-- than 30 days by server clock (received_at) so a skewed client clock
|
||||
-- can't keep rows alive. Matches the other gc lifecycle sweeps'
|
||||
-- hardcoded-threshold style; diagnostics are debug telemetry, not user
|
||||
-- data, and 30 days is ample to investigate a live incident after the
|
||||
-- fact. Returns the affected row count for the sweep log line.
|
||||
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days';
|
||||
@@ -55,7 +55,8 @@ WHERE id = $1;
|
||||
|
||||
-- name: ListUsers :many
|
||||
-- Admin user-management list. Sort newest-first.
|
||||
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
|
||||
SELECT id, username, display_name, is_admin, auto_approve_requests,
|
||||
debug_mode_enabled, created_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
@@ -115,6 +116,15 @@ UPDATE users
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: SetDebugMode :one
|
||||
-- Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
|
||||
-- from /admin (flip remotely while a bug is live) or self-driven OFF from
|
||||
-- the client. Returns the updated row so the handler can echo it.
|
||||
UPDATE users
|
||||
SET debug_mode_enabled = $2
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ChangeUserPassword :exec
|
||||
-- Self-service password change. Caller (HTTP handler) verifies the
|
||||
-- current password before calling this. Distinct from
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// - GcExpireScrobbleQueueFailedRows (#567)
|
||||
// - GcResetStuckSystemPlaylistRuns (#574)
|
||||
// - GcDeleteExpiredPasswordResets (#575)
|
||||
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
|
||||
package gc
|
||||
|
||||
import (
|
||||
@@ -82,6 +83,7 @@ func (w *Worker) tickOnce(ctx context.Context) {
|
||||
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
|
||||
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
||||
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
||||
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
|
||||
}
|
||||
|
||||
// runSweep is a small adapter so each sweep call site is a one-liner
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||
)
|
||||
|
||||
@@ -47,14 +48,21 @@ type Scheduler struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
dataDir string
|
||||
bus *eventbus.Bus // #968: announce rebuilds so clients self-refresh
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
|
||||
}
|
||||
|
||||
// NewScheduler builds an idle scheduler. Caller must invoke Start
|
||||
// before any builds fire.
|
||||
func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Scheduler, error) {
|
||||
// before any builds fire. bus may be nil (tests); rebuild events are
|
||||
// then skipped.
|
||||
func NewScheduler(
|
||||
pool *pgxpool.Pool,
|
||||
logger *slog.Logger,
|
||||
dataDir string,
|
||||
bus *eventbus.Bus,
|
||||
) (*Scheduler, error) {
|
||||
g, err := gocron.NewScheduler()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init gocron: %w", err)
|
||||
@@ -64,6 +72,7 @@ func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Sch
|
||||
pool: pool,
|
||||
logger: logger,
|
||||
dataDir: dataDir,
|
||||
bus: bus,
|
||||
jobs: map[pgtype.UUID]uuid.UUID{},
|
||||
}, nil
|
||||
}
|
||||
@@ -234,7 +243,26 @@ func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, no
|
||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil {
|
||||
s.logger.Warn("scheduler: build failed",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return
|
||||
}
|
||||
// #968: tell the user's connected clients their home content was
|
||||
// regenerated, so a tab/app left open across the rebuild refreshes its
|
||||
// system-playlist + You-might-like views (and a stale active queue can
|
||||
// re-pull) instead of serving yesterday's snapshot until a manual reload.
|
||||
s.publishRebuilt(userID)
|
||||
}
|
||||
|
||||
// publishRebuilt broadcasts a user-scoped "system playlists rebuilt" event.
|
||||
// No-op when the bus is nil (test construction).
|
||||
func (s *Scheduler) publishRebuilt(userID pgtype.UUID) {
|
||||
if s.bus == nil {
|
||||
return
|
||||
}
|
||||
s.bus.Publish(eventbus.Event{
|
||||
Kind: "playlist.system_rebuilt",
|
||||
UserID: uuidStringPL(userID),
|
||||
Data: map[string]any{},
|
||||
})
|
||||
}
|
||||
|
||||
// Stop drains gocron and stops the scheduler loop.
|
||||
|
||||
@@ -391,6 +391,7 @@ export type AdminUser = {
|
||||
display_name: string | null;
|
||||
is_admin: boolean;
|
||||
auto_approve_requests: boolean;
|
||||
debug_mode_enabled: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
@@ -451,6 +452,12 @@ export async function updateUserAutoApprove(id: string, autoApprove: boolean): P
|
||||
return api.put<AdminUser>(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove });
|
||||
}
|
||||
|
||||
// Flip an account's diagnostics/debug-reporting opt-in (M9). Admin-set;
|
||||
// the client obeys it (with a local per-device OFF switch).
|
||||
export async function updateUserDebugMode(id: string, enabled: boolean): Promise<AdminUser> {
|
||||
return api.put<AdminUser>(`/api/admin/users/${id}/debug-mode`, { enabled });
|
||||
}
|
||||
|
||||
export function createAdminUsersQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.adminUsers(),
|
||||
@@ -497,3 +504,82 @@ export function createSMTPConfigQuery() {
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
// Device diagnostics (M9) ---------------------------------------------------
|
||||
|
||||
// Coarse event category. The finer event sub-type lives inside `payload`.
|
||||
export type DiagnosticKind =
|
||||
| 'connectivity'
|
||||
| 'upnp_sync'
|
||||
| 'power'
|
||||
| 'lifecycle'
|
||||
| 'heartbeat'
|
||||
| 'http';
|
||||
|
||||
export type AdminDiagnostic = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
client_id: string;
|
||||
app_version?: string;
|
||||
os_version?: string;
|
||||
kind: DiagnosticKind | string;
|
||||
payload: Record<string, unknown>;
|
||||
occurred_at: string;
|
||||
received_at: string;
|
||||
};
|
||||
|
||||
export type AdminDiagnosticDevice = {
|
||||
client_id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
app_version: string;
|
||||
os_version: string;
|
||||
last_seen: string;
|
||||
event_count: number;
|
||||
};
|
||||
|
||||
export type DiagnosticsFilter = {
|
||||
userId?: string;
|
||||
clientId?: string;
|
||||
kind?: string;
|
||||
from?: string; // RFC3339
|
||||
to?: string; // RFC3339
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export async function listAdminDiagnostics(f: DiagnosticsFilter): Promise<AdminDiagnostic[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (f.userId) params.set('user_id', f.userId);
|
||||
if (f.clientId) params.set('client_id', f.clientId);
|
||||
if (f.kind) params.set('kind', f.kind);
|
||||
if (f.from) params.set('from', f.from);
|
||||
if (f.to) params.set('to', f.to);
|
||||
if (f.limit !== undefined) params.set('limit', String(f.limit));
|
||||
const qs = params.toString();
|
||||
return api.get<AdminDiagnostic[]>(qs ? `/api/admin/diagnostics?${qs}` : '/api/admin/diagnostics');
|
||||
}
|
||||
|
||||
export async function listDiagnosticDevices(userId?: string): Promise<AdminDiagnosticDevice[]> {
|
||||
const qs = userId ? `?user_id=${userId}` : '';
|
||||
return api.get<AdminDiagnosticDevice[]>(`/api/admin/diagnostics/devices${qs}`);
|
||||
}
|
||||
|
||||
export function createAdminDiagnosticsQuery(f: DiagnosticsFilter) {
|
||||
return createQuery({
|
||||
queryKey: qk.adminDiagnostics(f as Record<string, string | number | undefined>),
|
||||
queryFn: () => listAdminDiagnostics(f),
|
||||
// The operator enables debug then watches events stream in; a short
|
||||
// poll keeps the timeline live without manual refresh.
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000
|
||||
});
|
||||
}
|
||||
|
||||
export function createDiagnosticDevicesQuery(userId?: string) {
|
||||
return createQuery({
|
||||
queryKey: qk.adminDiagnosticDevices(userId),
|
||||
queryFn: () => listDiagnosticDevices(userId),
|
||||
staleTime: 15_000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { removeTrack } from './tracks';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubFetch(status: number, body: unknown, init: Partial<Response> = {}) {
|
||||
const res = new Response(
|
||||
body === null ? null : JSON.stringify(body),
|
||||
{ status, headers: { 'Content-Type': 'application/json' }, ...init }
|
||||
);
|
||||
const spy = vi.fn().mockResolvedValue(res);
|
||||
vi.stubGlobal('fetch', spy);
|
||||
return spy;
|
||||
}
|
||||
|
||||
describe('removeTrack', () => {
|
||||
test('DELETEs /api/admin/tracks/:id with no query string when unmonitor unset', async () => {
|
||||
const spy = stubFetch(200, {
|
||||
deleted_track_id: 't-1',
|
||||
deleted_album_id: 'al-1'
|
||||
});
|
||||
|
||||
const r = await removeTrack('t-1');
|
||||
expect(r.deleted_track_id).toBe('t-1');
|
||||
expect(r.deleted_album_id).toBe('al-1');
|
||||
expect(r.deleted_artist_id).toBeUndefined();
|
||||
expect(r.lidarr_unmonitor_failed).toBeUndefined();
|
||||
|
||||
const call = spy.mock.calls[0];
|
||||
expect(call[0]).toBe('/api/admin/tracks/t-1');
|
||||
expect((call[1] as RequestInit).method).toBe('DELETE');
|
||||
});
|
||||
|
||||
test('appends ?unmonitor=true when option set', async () => {
|
||||
const spy = stubFetch(200, {
|
||||
deleted_track_id: 't-1',
|
||||
lidarr_unmonitor_failed: true
|
||||
});
|
||||
|
||||
const r = await removeTrack('t-1', { unmonitor: true });
|
||||
expect(r.lidarr_unmonitor_failed).toBe(true);
|
||||
|
||||
const call = spy.mock.calls[0];
|
||||
expect(call[0]).toBe('/api/admin/tracks/t-1?unmonitor=true');
|
||||
});
|
||||
|
||||
test('omits the query string when unmonitor: false', async () => {
|
||||
const spy = stubFetch(200, { deleted_track_id: 't-1' });
|
||||
|
||||
await removeTrack('t-1', { unmonitor: false });
|
||||
const call = spy.mock.calls[0];
|
||||
expect(call[0]).toBe('/api/admin/tracks/t-1');
|
||||
});
|
||||
|
||||
test('surfaces not_found as ApiError', async () => {
|
||||
stubFetch(404, { error: 'not_found' });
|
||||
|
||||
await expect(removeTrack('t-1')).rejects.toMatchObject({
|
||||
code: 'not_found'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
|
||||
export type RemoveTrackResult = {
|
||||
deleted_track_id: string;
|
||||
deleted_album_id?: string;
|
||||
deleted_artist_id?: string;
|
||||
/** Only present (always `true` when set) when the operator requested
|
||||
* unmonitor=true AND the Lidarr unmonitor call failed. The destructive
|
||||
* file + DB delete already succeeded — surface a follow-up toast so
|
||||
* the operator knows to manually unmonitor in Lidarr. */
|
||||
lidarr_unmonitor_failed?: true;
|
||||
};
|
||||
|
||||
export type RemoveTrackOptions = {
|
||||
/** When true, after the file + DB delete the server calls
|
||||
* Lidarr.UnmonitorTrack so Lidarr won't search for a replacement.
|
||||
* When false / omitted, no Lidarr call — Lidarr's monitoring will
|
||||
* re-import on next scan, which is the operator's "find a replacement"
|
||||
* path. */
|
||||
unmonitor?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/tracks/{id} — admin-only. Deletes the file from
|
||||
* disk and the DB row, cascading album/artist tidy-up. Optionally
|
||||
* unmonitors the track in Lidarr.
|
||||
*
|
||||
* On success returns the deleted track id plus optional album/artist
|
||||
* ids when their parent row was tidied up by the server-side cascade.
|
||||
* Caller is responsible for invalidating any TanStack Query keys the
|
||||
* deleted entities backed.
|
||||
*/
|
||||
export async function removeTrack(
|
||||
id: string,
|
||||
options: RemoveTrackOptions = {}
|
||||
): Promise<RemoveTrackResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.unmonitor) params.set('unmonitor', 'true');
|
||||
const qs = params.toString();
|
||||
const url = `/api/admin/tracks/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`;
|
||||
return (await apiFetch(url, { method: 'DELETE' })) as RemoveTrackResult;
|
||||
}
|
||||
@@ -50,6 +50,10 @@ export const qk = {
|
||||
coverProviders: () => ['coverProviders'] as const,
|
||||
adminUsers: () => ['adminUsers'] as const,
|
||||
adminInvites: () => ['adminInvites'] as const,
|
||||
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
|
||||
['adminDiagnostics', f] as const,
|
||||
adminDiagnosticDevices: (userId?: string) =>
|
||||
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
||||
smtpConfig: () => ['smtpConfig'] as const,
|
||||
suggestions: (limit?: number) =>
|
||||
['suggestions', { limit: limit ?? 12 }] as const,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{ href: '/admin/requests', label: 'Requests' },
|
||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
||||
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
||||
{ href: '/admin/users', label: 'Users' }
|
||||
];
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('renders all six tabs in order', () => {
|
||||
test('renders all seven tabs in order', () => {
|
||||
state.pageUrl = new URL('http://localhost/admin');
|
||||
render(AdminTabs);
|
||||
const links = screen.getAllByRole('link');
|
||||
@@ -62,6 +62,7 @@ describe('AdminTabs', () => {
|
||||
'Requests',
|
||||
'Quarantine',
|
||||
'Playback errors',
|
||||
'Diagnostics',
|
||||
'Users'
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||
import { api } from '$lib/api/client';
|
||||
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
||||
import { Play } from 'lucide-svelte';
|
||||
import AlbumMenu from './AlbumMenu.svelte';
|
||||
import CardActionCluster from './CardActionCluster.svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
|
||||
let { album }: { album: AlbumRef } = $props();
|
||||
|
||||
function onImgError(e: Event) {
|
||||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
||||
}
|
||||
|
||||
async function onAddClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -38,12 +34,9 @@
|
||||
shadow-sm transition-all duration-150
|
||||
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
||||
>
|
||||
<img
|
||||
<Cover
|
||||
src={album.cover_url}
|
||||
alt=""
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
onerror={onImgError}
|
||||
class="transition-transform group-hover:scale-[1.03]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
||||
import { api } from '$lib/api/client';
|
||||
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
|
||||
import { Disc3, Play } from 'lucide-svelte';
|
||||
import { Play } from 'lucide-svelte';
|
||||
import ArtistMenu from './ArtistMenu.svelte';
|
||||
import CardActionCluster from './CardActionCluster.svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
import { listArtistTracks } from '$lib/api/artists';
|
||||
|
||||
let { artist }: { artist: ArtistRef } = $props();
|
||||
@@ -48,18 +49,12 @@
|
||||
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
|
||||
{#if artist.cover_url}
|
||||
<img
|
||||
src={artist.cover_url}
|
||||
alt=""
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
|
||||
</div>
|
||||
{/if}
|
||||
<Cover
|
||||
src={artist.cover_url}
|
||||
shape="round"
|
||||
fallback="artist"
|
||||
class="transition-transform group-hover:scale-[1.03]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Play ${artist.name}`}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||
import { coverUrl } from '$lib/media/covers';
|
||||
import TrackMenu from './TrackMenu.svelte';
|
||||
import CardActionCluster from './CardActionCluster.svelte';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
|
||||
// Horizontal compact track row — cover thumb on the left, title +
|
||||
// artist on the right. Mirrors Android's CompactTrackTile so the
|
||||
@@ -22,10 +24,6 @@
|
||||
|
||||
const cover = $derived(coverUrl(track.album_id));
|
||||
|
||||
function onImgError(e: Event) {
|
||||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
playQueue(sectionTracks, index);
|
||||
}
|
||||
@@ -42,31 +40,40 @@
|
||||
type="button"
|
||||
aria-label={`Play ${track.title}`}
|
||||
onclick={onClick}
|
||||
class="flex w-full items-center gap-2 rounded-md p-1 pr-12 text-left
|
||||
class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left
|
||||
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onerror={onImgError}
|
||||
/>
|
||||
<Cover src={cover} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
|
||||
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
|
||||
</div>
|
||||
</button>
|
||||
<CardActionCluster
|
||||
likeEntityType="track"
|
||||
likeEntityId={track.id}
|
||||
onAdd={onAddToQueue}
|
||||
addLabel={`Add ${track.title} to queue`}
|
||||
<!-- Single inline cluster, vertically centred on the right. The shared
|
||||
CardActionCluster splits Like+Add (top) and the menu (bottom) into
|
||||
opposite corners — correct for the tall square Album/Artist cards but
|
||||
it makes the two groups collide on this short one-line row. A compact
|
||||
row keeps all three controls in one horizontal group instead. -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute right-2 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5
|
||||
opacity-0 transition-opacity duration-150
|
||||
group-hover:opacity-100 focus-within:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#snippet menu()}
|
||||
<TrackMenu {track} />
|
||||
{/snippet}
|
||||
</CardActionCluster>
|
||||
<LikeButton entityType="track" entityId={track.id} size="sm" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Add ${track.title} to queue`}
|
||||
onclick={onAddToQueue}
|
||||
class="rounded-full bg-surface p-1 text-text-secondary hover:text-text-primary
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Plus size={16} strokeWidth={1} />
|
||||
</button>
|
||||
<TrackMenu {track} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||
|
||||
vi.mock('$lib/api/admin/tracks', () => ({
|
||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
||||
|
||||
vi.mock('$lib/auth/store.svelte', () => ({
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||
import { Disc3 } from 'lucide-svelte';
|
||||
|
||||
// Shared cover-artwork renderer. The web counterpart of Android's
|
||||
// CoverTile/ServerImage: one place that owns the loading placeholder and
|
||||
// the error fallback so no card sits blank while art loads or 404s (the
|
||||
// "You might like" row surfaces unplayed items whose art is often not yet
|
||||
// backfilled). Replaces the three hand-rolled <img> tags that previously
|
||||
// disagreed on fallback handling.
|
||||
let {
|
||||
src,
|
||||
alt = '',
|
||||
shape = 'square',
|
||||
fallback = 'image',
|
||||
class: klass = ''
|
||||
}: {
|
||||
src: string | undefined;
|
||||
alt?: string;
|
||||
// 'round' clips to a circle (artist avatars); 'square' leaves clipping
|
||||
// to the parent so existing rounded-md / rounded corners still apply.
|
||||
shape?: 'square' | 'round';
|
||||
// 'artist' degrades to a Disc3 icon; 'image' to the static placeholder
|
||||
// cover. A null/empty src is treated the same as a failed load.
|
||||
fallback?: 'image' | 'artist';
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
// Per-image lifecycle. Reset when src changes so a reused card (virtualized
|
||||
// list, client-side nav) never keeps a stale 'loaded'/'failed'.
|
||||
let loaded = $state(false);
|
||||
let failed = $state(false);
|
||||
$effect(() => {
|
||||
void src;
|
||||
loaded = false;
|
||||
failed = false;
|
||||
});
|
||||
|
||||
const useIcon = $derived(fallback === 'artist' && (failed || !src));
|
||||
// On failure for the image fallback, swap to the static placeholder asset.
|
||||
// src doesn't change on the second error, so there's no reload loop.
|
||||
const imgSrc = $derived(failed ? FALLBACK_COVER : src);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden bg-surface-hover {shape === 'round'
|
||||
? 'rounded-full'
|
||||
: ''}"
|
||||
>
|
||||
{#if useIcon}
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
|
||||
</div>
|
||||
{:else if imgSrc}
|
||||
<img
|
||||
src={imgSrc}
|
||||
{alt}
|
||||
class="h-full w-full object-cover transition-opacity duration-200 {loaded
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'} {klass}"
|
||||
loading="lazy"
|
||||
onload={() => (loaded = true)}
|
||||
onerror={() => (failed = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -272,8 +272,12 @@
|
||||
data-testid="player-bar-desktop"
|
||||
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
|
||||
>
|
||||
<!-- Left: cover + title + artist + like + menu -->
|
||||
<div class="flex w-72 min-w-0 items-center gap-3">
|
||||
<!-- Left: cover + title + artist + like + menu.
|
||||
basis-72 grow max-w-md: holds its 288px floor at the md breakpoint
|
||||
(where the seek column is the flex-1 that absorbs the remainder), but
|
||||
on wider screens it grows up to 448px instead of leaving the title
|
||||
truncated while the seek bar hogs the slack. -->
|
||||
<div class="flex basis-72 grow max-w-md min-w-0 items-center gap-3">
|
||||
<a
|
||||
href="/now-playing"
|
||||
aria-label="Open now playing"
|
||||
@@ -318,7 +322,10 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col items-stretch gap-2">
|
||||
<!-- max-w-xl + mx-auto caps the seek bar (and the transport row) at
|
||||
576px and centres it, so on wide screens the extra width flows to
|
||||
the title column on the left rather than stretching the scrubber. -->
|
||||
<div class="flex flex-1 flex-col items-stretch gap-2 mx-auto w-full max-w-xl">
|
||||
<!-- Transport row -->
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
||||
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
||||
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { playQueue } from '$lib/player/store.svelte';
|
||||
@@ -80,7 +81,14 @@
|
||||
const detail = await systemShuffle(variant);
|
||||
const refs = toTrackRefs(detail.tracks);
|
||||
if (refs.length > 0) {
|
||||
playQueue(refs, 0, { source: variant });
|
||||
playQueue(refs, 0, {
|
||||
source: variant,
|
||||
refetch: systemPlaylistRefetch({
|
||||
variant,
|
||||
playlistId: playlist.id,
|
||||
perArtist: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const detail = await getPlaylist(playlist.id);
|
||||
@@ -93,7 +101,14 @@
|
||||
// so source attribution stays absent — the PlaylistCard
|
||||
// test pins this contract.
|
||||
if (variant != null) {
|
||||
playQueue(refs, 0, { source: variant });
|
||||
playQueue(refs, 0, {
|
||||
source: variant,
|
||||
refetch: systemPlaylistRefetch({
|
||||
variant,
|
||||
playlistId: playlist.id,
|
||||
perArtist: true,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
playQueue(refs, 0);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||
|
||||
vi.mock('$lib/api/admin/tracks', () => ({
|
||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playNext: vi.fn(),
|
||||
enqueueTrack: vi.fn(),
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Trash2 } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { removeTrack } from '$lib/api/admin/tracks';
|
||||
import { errMessage } from '$lib/api/errors';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
let {
|
||||
track,
|
||||
onClose
|
||||
}: {
|
||||
track: TrackRef;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
let unmonitor = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function confirm() {
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await removeTrack(track.id, { unmonitor });
|
||||
// Invalidate caches for any vanished entity. The album/artist that
|
||||
// contained the track is always touched; if the parent rows were
|
||||
// tidied up by the server cascade, evict those too. Home payload
|
||||
// surfaces albums/artists on the landing page so it always
|
||||
// refreshes.
|
||||
await queryClient.invalidateQueries({ queryKey: qk.album(track.album_id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.artist(track.artist_id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.home() });
|
||||
if (result.deleted_album_id) {
|
||||
await queryClient.invalidateQueries({ queryKey: qk.album(result.deleted_album_id) });
|
||||
}
|
||||
if (result.deleted_artist_id) {
|
||||
await queryClient.invalidateQueries({ queryKey: qk.artist(result.deleted_artist_id) });
|
||||
}
|
||||
// Note: result.lidarr_unmonitor_failed is true when unmonitor was
|
||||
// requested AND Lidarr couldn't be reached. The destructive part
|
||||
// already succeeded; we still close the popover. A future toast
|
||||
// surface (if any) can pick up the flag.
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
error = errMessage(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
aria-modal="false"
|
||||
aria-labelledby="remove-track-popover-title"
|
||||
class="absolute right-0 z-30 mt-1 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose(); }}
|
||||
>
|
||||
<h4 id="remove-track-popover-title" class="text-sm font-medium text-text-primary">
|
||||
Remove "{track.title}" from your library?
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-text-muted">
|
||||
This deletes the file from disk.
|
||||
</p>
|
||||
|
||||
<label class="mt-3 flex items-start gap-2 text-sm text-text-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={unmonitor}
|
||||
class="mt-0.5 rounded border-border"
|
||||
/>
|
||||
<span>Also stop Lidarr from finding a replacement</span>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<p class="mt-2 text-xs text-action-destructive">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-2.5 py-1 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
||||
onclick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={confirm}
|
||||
disabled={busy}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border bg-surface px-2.5 py-1 text-sm text-action-destructive hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1} />
|
||||
{busy ? 'Removing…' : 'Remove'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,97 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { makeTrack } from '$test-utils/fixtures/track';
|
||||
|
||||
const invalidateMock = vi.fn();
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useQueryClient: () => ({ invalidateQueries: invalidateMock })
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/admin/tracks', () => ({
|
||||
removeTrack: vi.fn()
|
||||
}));
|
||||
|
||||
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
|
||||
import { removeTrack } from '$lib/api/admin/tracks';
|
||||
|
||||
const track = makeTrack({ title: 'Roygbiv' });
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('RemoveTrackPopover', () => {
|
||||
test('renders the title and subtext', () => {
|
||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
||||
expect(screen.getByText(/remove "roygbiv" from your library/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/this deletes the file from disk/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the Lidarr unmonitor checkbox', () => {
|
||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
||||
const cb = screen.getByRole('checkbox', { name: /also stop lidarr/i });
|
||||
expect(cb).toBeInTheDocument();
|
||||
expect((cb as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
test('Cancel button calls onClose without firing removeTrack', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(removeTrack).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Remove without checkbox calls removeTrack with unmonitor=false', async () => {
|
||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
|
||||
const onClose = vi.fn();
|
||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
||||
// confirm() chains 3-5 awaited invalidateQueries calls before onClose,
|
||||
// each a separate microtask. waitFor polls until the side effects land.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: false });
|
||||
});
|
||||
|
||||
test('Remove with checkbox checked calls removeTrack with unmonitor=true', async () => {
|
||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
|
||||
const onClose = vi.fn();
|
||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
||||
await fireEvent.click(screen.getByRole('checkbox', { name: /also stop lidarr/i }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: true });
|
||||
});
|
||||
|
||||
test('failed remove surfaces error via copyForCode and does not close', async () => {
|
||||
(removeTrack as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'not_found' });
|
||||
const onClose = vi.fn();
|
||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
||||
await waitFor(() => expect(removeTrack).toHaveBeenCalled());
|
||||
// The catch path runs synchronously after the rejected await; one more
|
||||
// microtask boundary is enough.
|
||||
await Promise.resolve();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('success invalidates album, artist and home queries', async () => {
|
||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
deleted_track_id: 't1',
|
||||
deleted_album_id: 'a1',
|
||||
deleted_artist_id: 'ar1'
|
||||
});
|
||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
||||
// Wait for all three baseline invalidations (album, artist, home) plus
|
||||
// the two cascade ones (deleted_album_id, deleted_artist_id) to land.
|
||||
await waitFor(() => expect(invalidateMock.mock.calls.length).toBeGreaterThanOrEqual(5));
|
||||
const keys = invalidateMock.mock.calls.map((c) => c[0].queryKey);
|
||||
const flat = keys.map((k) => Array.isArray(k) ? k.join('|') : String(k));
|
||||
expect(flat.some((k) => k.startsWith('album|a1'))).toBe(true);
|
||||
expect(flat.some((k) => k.startsWith('artist|ar1'))).toBe(true);
|
||||
expect(flat.some((k) => k === 'home')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,27 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
MoreVertical,
|
||||
ListPlus,
|
||||
Plus,
|
||||
ListVideo,
|
||||
ListMusic,
|
||||
Heart,
|
||||
HeartOff,
|
||||
ListMusic,
|
||||
Album,
|
||||
ListPlus,
|
||||
Radio,
|
||||
Disc3,
|
||||
Flag,
|
||||
User,
|
||||
EyeOff,
|
||||
Eye,
|
||||
Trash2
|
||||
Eye
|
||||
} from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import FlagPopover from './FlagPopover.svelte';
|
||||
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
|
||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
||||
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
|
||||
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
let {
|
||||
@@ -44,7 +41,6 @@
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let flagOpen = $state(false);
|
||||
let removeOpen = $state(false);
|
||||
let addOpen = $state(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
@@ -63,8 +59,6 @@
|
||||
return rows.some((r) => r.track_id === track.id);
|
||||
});
|
||||
|
||||
const isAdmin = $derived(user.value?.is_admin === true);
|
||||
|
||||
function toggleMenu(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
menuOpen = !menuOpen;
|
||||
@@ -73,7 +67,6 @@
|
||||
function closeAll() {
|
||||
menuOpen = false;
|
||||
flagOpen = false;
|
||||
removeOpen = false;
|
||||
addOpen = false;
|
||||
}
|
||||
|
||||
@@ -87,6 +80,11 @@
|
||||
closeAll();
|
||||
}
|
||||
|
||||
function onStartRadio() {
|
||||
playRadio(track.id);
|
||||
closeAll();
|
||||
}
|
||||
|
||||
async function onToggleLike() {
|
||||
closeAll();
|
||||
if (liked) {
|
||||
@@ -119,18 +117,8 @@
|
||||
goto(`/artists/${track.artist_id}`);
|
||||
}
|
||||
|
||||
function onFlag() {
|
||||
menuOpen = false;
|
||||
flagOpen = true;
|
||||
}
|
||||
|
||||
function onRemoveOpen() {
|
||||
menuOpen = false;
|
||||
removeOpen = true;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return;
|
||||
if (!menuOpen && !flagOpen && !addOpen) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAll();
|
||||
@@ -161,9 +149,16 @@
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') menuOpen = false; }}
|
||||
>
|
||||
<!-- Item set + order mirror Android's canonical TrackActionsSheet so
|
||||
the kebab reads the same on both clients: queue group → like /
|
||||
add-to-playlist / start-radio group → navigation group → hide.
|
||||
"Start radio" is shown even under hideQueueActions (reseeding a
|
||||
station from the current track is meaningful where play-next /
|
||||
add-to-queue are not). No single-click destructive action lives
|
||||
here — track removal is intentionally kept out of the kebab. -->
|
||||
{#if !hideQueueActions}
|
||||
<TrackMenuItem icon={ListPlus} label="Play next" onclick={onPlayNext} />
|
||||
<TrackMenuItem icon={Plus} label="Add to queue" onclick={onAddToQueue} />
|
||||
<TrackMenuItem icon={ListVideo} label="Play next" onclick={onPlayNext} />
|
||||
<TrackMenuItem icon={ListMusic} label="Add to queue" onclick={onAddToQueue} />
|
||||
|
||||
<TrackMenuDivider />
|
||||
{/if}
|
||||
@@ -174,32 +169,24 @@
|
||||
onclick={onToggleLike}
|
||||
/>
|
||||
<TrackMenuItem
|
||||
icon={ListMusic}
|
||||
icon={ListPlus}
|
||||
label="Add to playlist…"
|
||||
onclick={() => { addOpen = true; menuOpen = false; }}
|
||||
/>
|
||||
<TrackMenuItem icon={Radio} label="Start radio" onclick={onStartRadio} />
|
||||
|
||||
<TrackMenuDivider />
|
||||
|
||||
<TrackMenuItem icon={Album} label="Go to album" onclick={onGoToAlbum} />
|
||||
<TrackMenuItem icon={Disc3} label="Go to artist" onclick={onGoToArtist} />
|
||||
<TrackMenuItem icon={Disc3} label="Go to album" onclick={onGoToAlbum} />
|
||||
<TrackMenuItem icon={User} label="Go to artist" onclick={onGoToArtist} />
|
||||
|
||||
<TrackMenuDivider />
|
||||
|
||||
<TrackMenuItem icon={Flag} label="Flag this track…" onclick={onFlag} />
|
||||
<TrackMenuItem
|
||||
icon={hidden ? Eye : EyeOff}
|
||||
label={hidden ? 'Unhide' : 'Hide'}
|
||||
onclick={onToggleHide}
|
||||
/>
|
||||
{#if isAdmin}
|
||||
<TrackMenuItem
|
||||
icon={Trash2}
|
||||
label="Remove from library"
|
||||
onclick={onRemoveOpen}
|
||||
danger
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -207,10 +194,6 @@
|
||||
<FlagPopover {track} onClose={closeAll} />
|
||||
{/if}
|
||||
|
||||
{#if removeOpen}
|
||||
<RemoveTrackPopover {track} onClose={closeAll} />
|
||||
{/if}
|
||||
|
||||
{#if addOpen}
|
||||
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -25,19 +25,16 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||
|
||||
vi.mock('$lib/api/admin/tracks', () => ({
|
||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playNext: vi.fn(),
|
||||
enqueueTrack: vi.fn()
|
||||
enqueueTrack: vi.fn(),
|
||||
playRadio: vi.fn()
|
||||
}));
|
||||
|
||||
import TrackMenu from './TrackMenu.svelte';
|
||||
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
|
||||
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
||||
|
||||
const track = makeTrack({ title: 'Roygbiv' });
|
||||
|
||||
@@ -55,7 +52,7 @@ describe('TrackMenu', () => {
|
||||
expect(kebab.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
test('opening menu shows all 9 entries for an admin user', async () => {
|
||||
test('opening menu shows all 8 entries', async () => {
|
||||
render(TrackMenu, { props: { track } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||
|
||||
@@ -63,19 +60,12 @@ describe('TrackMenu', () => {
|
||||
expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /start radio/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(9);
|
||||
});
|
||||
|
||||
test('"Remove from library" hidden for non-admin', async () => {
|
||||
userState.current = { id: 'u2', username: 'normal', is_admin: false };
|
||||
render(TrackMenu, { props: { track } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||
// No "Remove from library" — destructive removal is intentionally absent.
|
||||
expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
|
||||
});
|
||||
@@ -113,10 +103,17 @@ describe('TrackMenu', () => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking "Flag this track…" opens FlagPopover and closes menu', async () => {
|
||||
test('"Start radio" dispatches playRadio(track.id)', async () => {
|
||||
render(TrackMenu, { props: { track } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: /flag this track/i }));
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: /start radio/i }));
|
||||
expect(playRadio).toHaveBeenCalledWith(track.id);
|
||||
});
|
||||
|
||||
test('clicking "Hide" opens the flag/quarantine popover and closes menu', async () => {
|
||||
render(TrackMenu, { props: { track } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: /^hide$/i }));
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /flag this track as broken/i })
|
||||
|
||||
@@ -83,6 +83,23 @@ let _radioRefreshInFlight = false;
|
||||
// queue clears it).
|
||||
let _queueSource = $state<string | null>(null);
|
||||
|
||||
// Track B (#968): consecutive load failures since the last successful
|
||||
// 'playing'. A bad track auto-advances instead of dead-ending; the streak
|
||||
// (capped against queue length) stops a fully-unplayable queue from
|
||||
// looping forever. Reset on a successful play and on a fresh playQueue.
|
||||
let _failureStreak = 0;
|
||||
|
||||
// Track B (#968): when a queue is seeded from a refreshable source (a system
|
||||
// playlist or radio), the seeder hands us a closure that re-pulls the fresh
|
||||
// snapshot. On total failure (whole queue unplayable — likely a stale tab
|
||||
// left open across the daily rebuild) we call it once to self-heal before
|
||||
// surfacing the dead-end. Kept opaque so the store stays decoupled from the
|
||||
// playlist API and the per-artist (songs_like_artist) identity problem.
|
||||
let _queueRefetch: (() => Promise<TrackRef[]>) | null = null;
|
||||
let _selfHealInFlight = false;
|
||||
let _selfHealAttempts = 0;
|
||||
const MAX_SELF_HEAL_ATTEMPTS = 1;
|
||||
|
||||
let _audioEl: HTMLAudioElement | null = null;
|
||||
|
||||
export const player = {
|
||||
@@ -109,13 +126,20 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
|
||||
export function playQueue(
|
||||
tracks: TrackRef[],
|
||||
startIndex = 0,
|
||||
opts: { shuffle?: boolean; source?: string | null } = {},
|
||||
opts: {
|
||||
shuffle?: boolean;
|
||||
source?: string | null;
|
||||
refetch?: () => Promise<TrackRef[]>;
|
||||
} = {},
|
||||
): void {
|
||||
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
||||
// #415: a fresh queue resets the system-playlist source. Set only
|
||||
// when seeded from a system playlist (server already returned the
|
||||
// rotation-aware order, so no client shuffle in that path).
|
||||
_queueSource = opts.source ?? null;
|
||||
// #968: a fresh play replaces the self-heal closure + resets its budget.
|
||||
_queueRefetch = opts.refetch ?? null;
|
||||
_selfHealAttempts = 0;
|
||||
if (opts.shuffle && tracks.length > 1) {
|
||||
// Fisher-Yates over the whole list. startIndex is ignored — the
|
||||
// caller is asking for "random play from this pool," so the first
|
||||
@@ -142,6 +166,7 @@ export function playQueue(
|
||||
_position = 0;
|
||||
_duration = 0;
|
||||
_error = null;
|
||||
_failureStreak = 0;
|
||||
}
|
||||
|
||||
export function togglePlay(): void {
|
||||
@@ -263,6 +288,8 @@ export function reportStateFromAudio(
|
||||
case 'playing':
|
||||
_state = 'playing';
|
||||
_error = null;
|
||||
_failureStreak = 0;
|
||||
_selfHealAttempts = 0;
|
||||
return;
|
||||
case 'paused':
|
||||
_state = 'paused';
|
||||
@@ -271,8 +298,7 @@ export function reportStateFromAudio(
|
||||
_state = 'loading';
|
||||
return;
|
||||
case 'error':
|
||||
_state = 'error';
|
||||
_error = detail ?? 'Playback failed.';
|
||||
handleLoadFailure(detail);
|
||||
return;
|
||||
case 'ended':
|
||||
if (_repeat === 'one') {
|
||||
@@ -301,6 +327,68 @@ export function reportStateFromAudio(
|
||||
}
|
||||
}
|
||||
|
||||
// Track B (#968): a single failed track must not strand the player on the
|
||||
// "Try again" dead-end. Advance past it; only surface the error once the
|
||||
// whole queue has proven unplayable — every track failed, or we reached the
|
||||
// end with nothing playable. The streak cap (vs queue length) stops a
|
||||
// fully-broken queue from cycling forever instead of settling.
|
||||
function handleLoadFailure(detail?: string): void {
|
||||
_failureStreak++;
|
||||
if (_failureStreak < _queue.length && _index + 1 < _queue.length) {
|
||||
_index++;
|
||||
_position = 0;
|
||||
_duration = 0;
|
||||
_state = 'loading';
|
||||
_error = null;
|
||||
return;
|
||||
}
|
||||
// Whole queue is unplayable. If it came from a refreshable source, the
|
||||
// snapshot is probably stale — re-pull it and resume before giving up.
|
||||
if (trySelfHeal()) return;
|
||||
_state = 'error';
|
||||
_error = detail ?? 'Playback failed.';
|
||||
}
|
||||
|
||||
// #968: re-pull a stale refreshable queue. Returns true if a self-heal was
|
||||
// started (caller must not dead-end). Bounded to one attempt per exhaustion
|
||||
// (reset on the next successful play) so a still-broken refresh can't loop.
|
||||
function trySelfHeal(): boolean {
|
||||
if (_selfHealInFlight) return true;
|
||||
if (_queueRefetch === null) return false;
|
||||
if (_selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) return false;
|
||||
_selfHealAttempts++;
|
||||
_selfHealInFlight = true;
|
||||
_state = 'loading';
|
||||
_error = null;
|
||||
const refetch = _queueRefetch;
|
||||
void refetch()
|
||||
.then((refs) => {
|
||||
if (refs.length > 0) {
|
||||
// Re-seed in place — keep _queueSource / _queueRefetch so the new
|
||||
// snapshot can itself self-heal, and don't reset _selfHealAttempts
|
||||
// (only a successful 'playing' clears the budget).
|
||||
_queue = refs;
|
||||
_index = 0;
|
||||
_position = 0;
|
||||
_duration = 0;
|
||||
_failureStreak = 0;
|
||||
_state = 'loading';
|
||||
_error = null;
|
||||
} else {
|
||||
_state = 'error';
|
||||
_error = 'Nothing playable in this mix right now.';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
_state = 'error';
|
||||
_error = 'Couldn’t refresh this mix. Try again.';
|
||||
})
|
||||
.finally(() => {
|
||||
_selfHealInFlight = false;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function enqueueTrack(t: TrackRef): void {
|
||||
_radioSeedId = null; // M4c
|
||||
_queue = [..._queue, t];
|
||||
@@ -345,12 +433,19 @@ export function playNextMany(ts: TrackRef[]): void {
|
||||
_queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
|
||||
}
|
||||
|
||||
export async function playRadio(seedTrackId: string): Promise<void> {
|
||||
async function fetchRadioTracks(seedTrackId: string): Promise<TrackRef[]> {
|
||||
const resp = await api.get<RadioResponse>(
|
||||
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
||||
);
|
||||
if (resp.tracks.length === 0) return;
|
||||
playQueue(resp.tracks, 0);
|
||||
return resp.tracks;
|
||||
}
|
||||
|
||||
export async function playRadio(seedTrackId: string): Promise<void> {
|
||||
const tracks = await fetchRadioTracks(seedTrackId);
|
||||
if (tracks.length === 0) return;
|
||||
// #968: hand the player a self-heal closure so a fully-stale radio queue
|
||||
// re-seeds from the same track instead of dead-ending.
|
||||
playQueue(tracks, 0, { refetch: () => fetchRadioTracks(seedTrackId) });
|
||||
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,50 @@ describe('player store — shuffle + repeat + audio reports', () => {
|
||||
expect(player.state).toBe('error');
|
||||
expect(player.error).toBe('network lost');
|
||||
});
|
||||
|
||||
test('reportStateFromAudio("error") auto-advances past a bad track', () => {
|
||||
playQueue([track('1'), track('2'), track('3')]);
|
||||
reportStateFromAudio('error', 'boom');
|
||||
expect(player.index).toBe(1);
|
||||
expect(player.state).toBe('loading');
|
||||
expect(player.error).toBeNull();
|
||||
});
|
||||
|
||||
test('reportStateFromAudio("error") dead-ends once the whole queue is unplayable', () => {
|
||||
playQueue([track('1'), track('2')]);
|
||||
reportStateFromAudio('error', 'boom'); // track 1 fails → skip to track 2
|
||||
expect(player.index).toBe(1);
|
||||
expect(player.state).toBe('loading');
|
||||
reportStateFromAudio('error', 'boom'); // track 2 also fails → exhausted
|
||||
expect(player.state).toBe('error');
|
||||
expect(player.error).toBe('boom');
|
||||
});
|
||||
|
||||
test('reportStateFromAudio("error") self-heals a refreshable queue on total failure', async () => {
|
||||
const fresh = [track('a'), track('b')];
|
||||
const refetch = vi.fn().mockResolvedValue(fresh);
|
||||
playQueue([track('1')], 0, { source: 'for_you', refetch });
|
||||
reportStateFromAudio('error', 'boom'); // sole track fails → exhausted → self-heal
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
expect(player.state).toBe('loading');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(player.queue.map((t) => t.id)).toEqual(['a', 'b']);
|
||||
expect(player.index).toBe(0);
|
||||
});
|
||||
|
||||
test('self-heal fires at most once per exhaustion, then dead-ends', async () => {
|
||||
const refetch = vi.fn().mockResolvedValue([track('x')]);
|
||||
playQueue([track('1')], 0, { source: 'for_you', refetch });
|
||||
reportStateFromAudio('error', 'boom'); // exhausted → self-heal #1
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
reportStateFromAudio('error', 'boom'); // re-pulled track also fails; budget spent → error
|
||||
expect(player.state).toBe('error');
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { TrackRef, PlaylistDetail } from '$lib/api/types';
|
||||
import { systemShuffle, getPlaylist } from '$lib/api/playlists';
|
||||
import { playlistTrackToRef } from './playlistTrackToRef';
|
||||
|
||||
// #968: builds the self-heal closure for a system-playlist queue. On total
|
||||
// playback failure (every queued track unplayable — typically a stale tab
|
||||
// left open across the daily rebuild) the player calls this to re-pull the
|
||||
// current snapshot and resume.
|
||||
//
|
||||
// Single-instance variants (for_you, discover, deep_cuts, …) re-pull by
|
||||
// variant via the rotation-aware shuffle endpoint. Per-artist mixes
|
||||
// (songs_like_artist — one playlist per seed artist) can't be addressed by
|
||||
// variant alone, so they re-pull by playlist id. Mirrors the play routing
|
||||
// in PlaylistCard.
|
||||
|
||||
function toRefs(detail: PlaylistDetail): TrackRef[] {
|
||||
return detail.tracks
|
||||
.map((r) => playlistTrackToRef(r))
|
||||
.filter((t): t is TrackRef => t !== null);
|
||||
}
|
||||
|
||||
export function systemPlaylistRefetch(opts: {
|
||||
variant: string;
|
||||
playlistId: string;
|
||||
perArtist: boolean;
|
||||
}): () => Promise<TrackRef[]> {
|
||||
return async () => {
|
||||
const detail = opts.perArtist
|
||||
? await getPlaylist(opts.playlistId)
|
||||
: await systemShuffle(opts.variant);
|
||||
return toRefs(detail);
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user