Compare commits
59 Commits
v2026.06.11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 725ddca950 | |||
| 29e1e7c64c | |||
| 9ad4343c76 | |||
| 0d0a8f46b1 | |||
| 9e02878b61 | |||
| 48f288e2e5 | |||
| 2be07ef271 | |||
| a670840114 | |||
| 5faa57634b | |||
| fb4431207d | |||
| 60533073ad | |||
| 4b150a277e | |||
| 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 | |||
| 3e258507bb | |||
| fd7d6dac4c | |||
| d6290a3ef0 | |||
| 9550d8daaf | |||
| 4d5af3599e | |||
| db393bbe65 | |||
| 1a7515e6ea | |||
| 6c26ba807e | |||
| c7adf2c87a |
@@ -2,8 +2,8 @@ name: release
|
|||||||
|
|
||||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||||
#
|
#
|
||||||
# push to main → :main and :latest (no APK bundled)
|
# push to main → :main and :latest (latest-release APK bundled)
|
||||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (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)
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||||
#
|
#
|
||||||
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
# 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
|
# happens in the same android-release job, so the Release-page download
|
||||||
# link and the in-image bundled APK are both populated atomically.
|
# 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)
|
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||||
# lives in android.yml and runs independently on every push.
|
# lives in android.yml and runs independently on every push.
|
||||||
|
|
||||||
@@ -181,6 +189,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Detect buildable project
|
||||||
id: guard
|
id: guard
|
||||||
@@ -221,9 +235,8 @@ jobs:
|
|||||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
- name: Download signed APK artifact
|
- name: Download signed APK artifact
|
||||||
# Tag pushes only — android-release just produced this. Main
|
# Tag pushes only — android-release just produced this. Non-tag
|
||||||
# pushes skip and the image ships with empty client/ (the
|
# builds take the "Bundle latest release APK" path below instead.
|
||||||
# /api/client/version endpoint then returns 404 by design).
|
|
||||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
@@ -247,6 +260,42 @@ jobs:
|
|||||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||||
ls -lh client/
|
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
|
- name: Build and push
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
run: |
|
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.
|
> 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
|
## 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.
|
- **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.
|
- **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.
|
- **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
|
## Quickstart
|
||||||
|
|
||||||
@@ -24,10 +24,18 @@ services:
|
|||||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||||
ports: ['4533:4533']
|
ports: ['4533:4533']
|
||||||
volumes:
|
volumes:
|
||||||
|
# Your music library. Point ./music at wherever your audio files
|
||||||
|
# live. Mounted read-only — Minstrel never writes to your library.
|
||||||
- ./music:/music:ro
|
- ./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:
|
environment:
|
||||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
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
|
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||||
depends_on: [db]
|
depends_on: [db]
|
||||||
|
|
||||||
@@ -37,6 +45,8 @@ services:
|
|||||||
POSTGRES_USER: minstrel
|
POSTGRES_USER: minstrel
|
||||||
POSTGRES_PASSWORD: minstrel
|
POSTGRES_PASSWORD: minstrel
|
||||||
POSTGRES_DB: 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: [pgdata:/var/lib/postgresql/data]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -48,7 +58,29 @@ volumes:
|
|||||||
docker compose up -d
|
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).
|
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:
|
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_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`).
|
- `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.
|
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
|
## Updating
|
||||||
|
|
||||||
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.
|
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||||
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
|
||||||
|
- `: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
|
## Specs
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import com.fabledsword.minstrel.cache.CacheIndexer
|
|||||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||||
@@ -155,6 +157,11 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
||||||
|
|
||||||
|
// Device diagnostics (M9). The reporter gates itself on the account's
|
||||||
|
// 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
|
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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 com.fabledsword.minstrel.models.Invite
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -25,6 +27,7 @@ data class AdminInvitesUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminInvitesViewModel @Inject constructor(
|
class AdminInvitesViewModel @Inject constructor(
|
||||||
private val repository: AdminInvitesRepository,
|
private val repository: AdminInvitesRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(AdminInvitesUiState())
|
private val internal = MutableStateFlow(AdminInvitesUiState())
|
||||||
@@ -36,6 +39,13 @@ class AdminInvitesViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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() {
|
fun refresh() {
|
||||||
|
|||||||
+4
-2
@@ -29,6 +29,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -62,9 +63,10 @@ fun AdminQuarantineScreen(
|
|||||||
body = "When users flag tracks as bad rips, wrong tags, or " +
|
body = "When users flag tracks as bad rips, wrong tags, or " +
|
||||||
"duplicates, their reports get aggregated and surfaced here.",
|
"duplicates, their reports get aggregated and surfaced here.",
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Error -> EmptyState(
|
is AdminQuarantineUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load queue",
|
title = "Couldn't load queue",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Success -> QueueList(
|
is AdminQuarantineUiState.Success -> QueueList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
+10
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -26,6 +28,7 @@ sealed interface AdminQuarantineUiState {
|
|||||||
class AdminQuarantineViewModel @Inject constructor(
|
class AdminQuarantineViewModel @Inject constructor(
|
||||||
private val repository: AdminQuarantineRepository,
|
private val repository: AdminQuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||||
@@ -38,6 +41,13 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// 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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -61,9 +62,10 @@ fun AdminRequestsScreen(
|
|||||||
body = "When users ask Lidarr for new music, their pending " +
|
body = "When users ask Lidarr for new music, their pending " +
|
||||||
"requests show up here for approval.",
|
"requests show up here for approval.",
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Error -> EmptyState(
|
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Success -> RequestList(
|
is AdminRequestsUiState.Success -> RequestList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -35,6 +37,7 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
private val repository: AdminRequestsRepository,
|
private val repository: AdminRequestsRepository,
|
||||||
private val usersRepository: AdminUsersRepository,
|
private val usersRepository: AdminUsersRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
||||||
@@ -47,6 +50,13 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// 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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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 com.fabledsword.minstrel.models.AdminUserRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -23,6 +25,7 @@ sealed interface AdminUsersUiState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminUsersViewModel @Inject constructor(
|
class AdminUsersViewModel @Inject constructor(
|
||||||
private val repository: AdminUsersRepository,
|
private val repository: AdminUsersRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||||
@@ -30,6 +33,13 @@ class AdminUsersViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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 {
|
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
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.UserRef
|
import com.fabledsword.minstrel.models.UserRef
|
||||||
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
||||||
@@ -35,6 +36,7 @@ class AuthController @Inject constructor(
|
|||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val api: AuthApi = retrofit.create()
|
private val api: AuthApi = retrofit.create()
|
||||||
|
private val meApi: MeApi = retrofit.create()
|
||||||
|
|
||||||
private val currentUserState = MutableStateFlow<UserRef?>(null)
|
private val currentUserState = MutableStateFlow<UserRef?>(null)
|
||||||
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
|
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
|
||||||
@@ -55,6 +57,10 @@ class AuthController @Inject constructor(
|
|||||||
currentUserState.value = raw?.let { decodeUser(it) }
|
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
|
currentUserState.value = user
|
||||||
authStore.setUserJson(json.encodeToString(UserRef.serializer(), 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
|
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
|
* Clears the local session immediately and best-effort hits
|
||||||
* `/api/auth/logout` so the server can drop its session row.
|
* `/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)
|
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
|
||||||
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
||||||
|
|
||||||
|
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||||
|
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -70,6 +73,7 @@ class AuthStore @Inject constructor(
|
|||||||
themeModeState.value = row?.themeMode
|
themeModeState.value = row?.themeMode
|
||||||
clientIdState.value = row?.clientId
|
clientIdState.value = row?.clientId
|
||||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||||
|
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +116,11 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistCacheSettings(encoded) }
|
scope.launch { persistCacheSettings(encoded) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDiagnosticsOptOut(value: Boolean) {
|
||||||
|
diagnosticsOptOutState.value = value
|
||||||
|
scope.launch { persistDiagnosticsOptOut(value) }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun persistCookie(value: String?) {
|
private suspend fun persistCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
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(
|
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||||
id = ROW_ID,
|
id = ROW_ID,
|
||||||
sessionCookie = sessionCookieState.value,
|
sessionCookie = sessionCookieState.value,
|
||||||
@@ -171,6 +188,7 @@ class AuthStore @Inject constructor(
|
|||||||
CacheSettings.serializer(),
|
CacheSettings.serializer(),
|
||||||
cacheSettingsState.value,
|
cacheSettingsState.value,
|
||||||
),
|
),
|
||||||
|
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
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.CachedPlaylistTrackDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
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.dao.SyncMetadataDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
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.CachedPlaylistTrackEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,8 +63,11 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
CachedHomeIndexEntity::class,
|
CachedHomeIndexEntity::class,
|
||||||
CachedHistorySnapshotEntity::class,
|
CachedHistorySnapshotEntity::class,
|
||||||
AuthSessionEntity::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,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
@@ -81,4 +86,5 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
|
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
|
||||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||||
abstract fun authSessionDao(): AuthSessionDao
|
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.CachedHomeIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
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.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||||
@@ -105,5 +106,10 @@ object DatabaseModule {
|
|||||||
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
|
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
|
||||||
db.audioCacheIndexDao()
|
db.audioCacheIndexDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||||
|
db.diagnosticEventDao()
|
||||||
|
|
||||||
private const val DATABASE_NAME = "minstrel.db"
|
private const val DATABASE_NAME = "minstrel.db"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -42,4 +42,8 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the serialized cache settings. */
|
/** Partial update: change only the serialized cache settings. */
|
||||||
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
|
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
|
||||||
suspend fun setCacheSettingsJson(json: String?)
|
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.
|
* CacheSettings shape evolves.
|
||||||
*/
|
*/
|
||||||
val cacheSettingsJson: String? = null,
|
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
|
package com.fabledsword.minstrel.cache.sync
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
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.SyncArtistWire
|
||||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||||
import kotlinx.coroutines.CoroutineScope
|
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.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
@@ -64,6 +68,17 @@ class SyncController @Inject constructor(
|
|||||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||||
private val mutex = Mutex()
|
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 {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
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() {
|
suspend fun syncSafe() {
|
||||||
if (!mutex.tryLock()) return
|
if (!mutex.tryLock()) return
|
||||||
try {
|
try {
|
||||||
runCatching { sync() }
|
runCatching { sync() }
|
||||||
|
.onSuccess { lastSyncErrorInternal.value = null }
|
||||||
|
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
|
||||||
} finally {
|
} finally {
|
||||||
mutex.unlock()
|
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.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -109,6 +110,7 @@ private fun DiscoverBody(
|
|||||||
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRetry = { viewModel.loadSuggestions() },
|
||||||
)
|
)
|
||||||
ResultsState.Loading -> LoadingCentered()
|
ResultsState.Loading -> LoadingCentered()
|
||||||
is ResultsState.Loaded -> ResultsList(
|
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,
|
state: SuggestionState,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SuggestionState.Loading -> LoadingCentered()
|
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(
|
is SuggestionState.Loaded -> SuggestionsList(
|
||||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||||
onRequest = onRequest,
|
onRequest = onRequest,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.discover.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.DiscoverRepository
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
@@ -46,6 +48,7 @@ sealed interface ResultsState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DiscoverViewModel @Inject constructor(
|
class DiscoverViewModel @Inject constructor(
|
||||||
private val repository: DiscoverRepository,
|
private val repository: DiscoverRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(DiscoverState())
|
private val internal = MutableStateFlow(DiscoverState())
|
||||||
@@ -53,6 +56,15 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadSuggestions()
|
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) {
|
fun setQuery(value: String) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.HistoryEntry
|
||||||
import com.fabledsword.minstrel.history.data.HistoryRepository
|
import com.fabledsword.minstrel.history.data.HistoryRepository
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
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.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -56,6 +59,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HistoryTabViewModel @Inject constructor(
|
class HistoryTabViewModel @Inject constructor(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -82,6 +86,11 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -116,9 +125,10 @@ fun HistoryTab(
|
|||||||
title = "No listening history yet",
|
title = "No listening history yet",
|
||||||
body = "Play something — your recent plays will show up here.",
|
body = "Play something — your recent plays will show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load history",
|
title = "Couldn't load history",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HistoryList(
|
is UiState.Success -> HistoryList(
|
||||||
entries = s.data,
|
entries = s.data,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
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.UiState
|
||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
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.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -139,6 +142,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||||
|
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
|
||||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -165,8 +169,31 @@ class HomeViewModel @Inject constructor(
|
|||||||
/** Transient snackbar messages from offline-pool taps. */
|
/** Transient snackbar messages from offline-pool taps. */
|
||||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
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 {
|
init {
|
||||||
refresh()
|
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.
|
* actual completion before hiding the indicator.
|
||||||
*/
|
*/
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
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 lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||||
val status = launch {
|
val status = launch {
|
||||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||||
@@ -325,9 +359,17 @@ class HomeViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun combineHomeFlows() =
|
private fun combineHomeFlows() =
|
||||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
combine(
|
||||||
|
observeHomeSections(),
|
||||||
|
playlistsRepository.observeAll(),
|
||||||
|
refreshError,
|
||||||
|
) { sections, playlists, err ->
|
||||||
val merged = sections.copy(playlists = playlists)
|
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 " +
|
"settings, then come back here for system playlists " +
|
||||||
"and recommendations.",
|
"and recommendations.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load home",
|
title = "Couldn't load home",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HomeSuccessContent(
|
is UiState.Success -> HomeSuccessContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
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.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
@@ -116,9 +116,10 @@ private fun AlbumDetailStateContent(
|
|||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is AlbumDetailUiState.Loading ->
|
is AlbumDetailUiState.Loading ->
|
||||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||||
is AlbumDetailUiState.Error -> EmptyState(
|
is AlbumDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load album",
|
title = "Couldn't load album",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AlbumDetailUiState.Success -> {
|
is AlbumDetailUiState.Success -> {
|
||||||
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||||
@@ -36,6 +38,7 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -64,6 +67,14 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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 {
|
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.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
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.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -107,9 +107,10 @@ fun ArtistDetailScreen(
|
|||||||
} else {
|
} else {
|
||||||
SkeletonArtistAlbumsGrid()
|
SkeletonArtistAlbumsGrid()
|
||||||
}
|
}
|
||||||
is ArtistDetailUiState.Error -> EmptyState(
|
is ArtistDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load artist",
|
title = "Couldn't load artist",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is ArtistDetailUiState.Success ->
|
is ArtistDetailUiState.Success ->
|
||||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||||
|
|||||||
+16
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
@@ -45,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -69,6 +72,19 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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() {
|
fun toggleLikeArtist() {
|
||||||
|
|||||||
@@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.observeArtists(),
|
repository.observeArtists(),
|
||||||
repository.observeAlbums(),
|
repository.observeAlbums(),
|
||||||
) { artists, albums ->
|
syncController.lastSyncError,
|
||||||
if (artists.isEmpty() && albums.isEmpty()) {
|
) { artists, albums, syncError ->
|
||||||
UiState.Empty
|
when {
|
||||||
} else {
|
artists.isNotEmpty() || albums.isNotEmpty() ->
|
||||||
UiState.Success(LibraryData(artists, albums))
|
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)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
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.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
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.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
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 com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -68,14 +73,25 @@ data class LikedSections(
|
|||||||
class LikedTabViewModel @Inject constructor(
|
class LikedTabViewModel @Inject constructor(
|
||||||
private val repository: LikesRepository,
|
private val repository: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Copy for the most recent refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshIds() }
|
runCatching { repository.refreshIds() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,9 +108,16 @@ class LikedTabViewModel @Inject constructor(
|
|||||||
repository.observeLikedArtists(),
|
repository.observeLikedArtists(),
|
||||||
repository.observeLikedAlbums(),
|
repository.observeLikedAlbums(),
|
||||||
repository.observeLikedTracks(),
|
repository.observeLikedTracks(),
|
||||||
) { artists, albums, tracks ->
|
refreshError,
|
||||||
|
) { artists, albums, tracks, err ->
|
||||||
val sections = LikedSections(artists, albums, tracks)
|
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)
|
}.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,9 +153,10 @@ fun LikedTab(
|
|||||||
body = "Tap the heart on an artist, album, or track to start " +
|
body = "Tap the heart on an artist, album, or track to start " +
|
||||||
"building your liked collection.",
|
"building your liked collection.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load likes",
|
title = "Couldn't load likes",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> LikedContent(
|
is UiState.Success -> LikedContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
@@ -16,4 +16,11 @@ data class UserRef(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
val isAdmin: Boolean = false,
|
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,
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
val email: String? = null,
|
val email: String? = null,
|
||||||
@SerialName("is_admin") val isAdmin: Boolean = false,
|
@SerialName("is_admin") val isAdmin: Boolean = false,
|
||||||
|
@SerialName("debug_mode_enabled") val debugModeEnabled: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds a WiFi + CPU wake lock for the lifetime of an active UPnP cast.
|
||||||
|
*
|
||||||
|
* While casting, the wrapped ExoPlayer is paused, so its automatic
|
||||||
|
* `WAKE_MODE_NETWORK` locks are released and nothing keeps the phone's radio
|
||||||
|
* awake. On a locked, on-battery phone the WiFi enters power-save within
|
||||||
|
* seconds and the CPU dozes; that stalls the 1 Hz liveness poll to the
|
||||||
|
* renderer and the queue-extend calls to the server. The poll then trips the
|
||||||
|
* drop threshold and playback falls back to a phone that *also* has no
|
||||||
|
* network — silence, while the renderer was streaming fine the whole time.
|
||||||
|
*
|
||||||
|
* A high-performance / low-latency [WifiManager.WifiLock] keeps the radio out
|
||||||
|
* of power-save, and a partial [PowerManager.WakeLock] keeps the poll
|
||||||
|
* coroutine scheduled. Both are acquired when a route goes active and released
|
||||||
|
* the moment it drops or the user switches back to the phone.
|
||||||
|
*
|
||||||
|
* All methods are idempotent (`setReferenceCounted(false)` + held-checks) and
|
||||||
|
* synchronized, so they are safe to call from any thread.
|
||||||
|
*/
|
||||||
|
class CastNetworkLock(context: Context) {
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
|
||||||
|
private val wifiLock: WifiManager.WifiLock? =
|
||||||
|
(appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)
|
||||||
|
?.createWifiLock(wifiLockMode(), LOCK_TAG)
|
||||||
|
?.apply { setReferenceCounted(false) }
|
||||||
|
|
||||||
|
private val wakeLock: PowerManager.WakeLock? =
|
||||||
|
(appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager)
|
||||||
|
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG)
|
||||||
|
?.apply { setReferenceCounted(false) }
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun acquire() {
|
||||||
|
runCatching { wifiLock?.takeUnless { it.isHeld }?.acquire() }
|
||||||
|
.onFailure { Timber.w(it, "CastNetworkLock: wifi acquire failed") }
|
||||||
|
runCatching { wakeLock?.takeUnless { it.isHeld }?.acquire() }
|
||||||
|
.onFailure { Timber.w(it, "CastNetworkLock: wake acquire failed") }
|
||||||
|
Timber.w(
|
||||||
|
"CastNetworkLock acquired (wifi=%s wake=%s)",
|
||||||
|
wifiLock?.isHeld, wakeLock?.isHeld,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun release() {
|
||||||
|
runCatching { wifiLock?.takeIf { it.isHeld }?.release() }
|
||||||
|
.onFailure { Timber.w(it, "CastNetworkLock: wifi release failed") }
|
||||||
|
runCatching { wakeLock?.takeIf { it.isHeld }?.release() }
|
||||||
|
.onFailure { Timber.w(it, "CastNetworkLock: wake release failed") }
|
||||||
|
Timber.w("CastNetworkLock released")
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LOCK_TAG = "minstrel:upnp-cast"
|
||||||
|
|
||||||
|
// WIFI_MODE_FULL_LOW_LATENCY (API 29+) both disables power-save and
|
||||||
|
// lowers latency; FULL_HIGH_PERF is the pre-Q equivalent for keeping
|
||||||
|
// the radio fully awake.
|
||||||
|
fun wifiLockMode(): Int =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-3
@@ -10,6 +10,8 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import androidx.media3.common.ForwardingPlayer
|
import androidx.media3.common.ForwardingPlayer
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
@@ -63,6 +65,8 @@ class MinstrelForwardingPlayer(
|
|||||||
private val delegate: Player,
|
private val delegate: Player,
|
||||||
private val holder: ActiveUpnpHolder,
|
private val holder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
|
private val castNetworkLock: CastNetworkLock,
|
||||||
|
private val networkStatus: NetworkStatusController,
|
||||||
private val onDrop: (routeName: String) -> Unit,
|
private val onDrop: (routeName: String) -> Unit,
|
||||||
) : ForwardingPlayer(delegate) {
|
) : ForwardingPlayer(delegate) {
|
||||||
|
|
||||||
@@ -116,6 +120,20 @@ class MinstrelForwardingPlayer(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
holder.active.collect { active -> onActiveChanged(active) }
|
holder.active.collect { active -> onActiveChanged(active) }
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
// When the phone's own network recovers, reconcile a held cast
|
||||||
|
// session immediately instead of waiting up to POLL_INTERVAL_MS:
|
||||||
|
// the renderer kept playing, so one round-trip re-confirms it and
|
||||||
|
// resyncs the cursor/position. Only a transition *into* Healthy
|
||||||
|
// while a route is active matters.
|
||||||
|
var prev = networkStatus.state.value
|
||||||
|
networkStatus.state.collect { next ->
|
||||||
|
if (next == ServerHealth.Healthy && prev != ServerHealth.Healthy && isRemote()) {
|
||||||
|
pollTrigger.trySend(Unit)
|
||||||
|
}
|
||||||
|
prev = next
|
||||||
|
}
|
||||||
|
}
|
||||||
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
||||||
// addObserver requires it. The observer just trySend's to the channel.
|
// addObserver requires it. The observer just trySend's to the channel.
|
||||||
handler.post {
|
handler.post {
|
||||||
@@ -438,6 +456,11 @@ class MinstrelForwardingPlayer(
|
|||||||
lastNotifiedTrackIdx = -1
|
lastNotifiedTrackIdx = -1
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||||
|
// Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
|
||||||
|
// about to be paused (releasing its WAKE_MODE_NETWORK locks), so
|
||||||
|
// without this the radio power-saves on a locked screen and the
|
||||||
|
// poll below starves -- see [CastNetworkLock].
|
||||||
|
castNetworkLock.acquire()
|
||||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||||
// simultaneously with the remote renderer. handler.post targets the
|
// simultaneously with the remote renderer. handler.post targets the
|
||||||
// application looper, so this runs on the same thread that processes
|
// application looper, so this runs on the same thread that processes
|
||||||
@@ -447,20 +470,26 @@ class MinstrelForwardingPlayer(
|
|||||||
handler.post { delegate.pause() }
|
handler.post { delegate.pause() }
|
||||||
pollJob = scope.launch { pollLoop(active) }
|
pollJob = scope.launch { pollLoop(active) }
|
||||||
} else {
|
} else {
|
||||||
|
castNetworkLock.release()
|
||||||
remoteState.reset()
|
remoteState.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||||
|
var networkDropSuppressed = false
|
||||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||||
val outcome = runCatching { pollOnce(active) }
|
val outcome = runCatching { pollOnce(active) }
|
||||||
if (outcome.isSuccess) {
|
if (outcome.isSuccess) {
|
||||||
remoteState.recordPollSuccess()
|
remoteState.recordPollSuccess()
|
||||||
|
networkDropSuppressed = false
|
||||||
} else if (remoteState.recordPollFailure()) {
|
} else if (remoteState.recordPollFailure()) {
|
||||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
if (networkStatus.state.value == ServerHealth.Healthy) {
|
||||||
handler.post { onDrop(active.routeName) }
|
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||||
return
|
handler.post { onDrop(active.routeName) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
|
||||||
}
|
}
|
||||||
// Race the normal cadence against any external wake (activity
|
// Race the normal cadence against any external wake (activity
|
||||||
// resume). Whichever wins continues to the next pollOnce.
|
// resume). Whichever wins continues to the next pollOnce.
|
||||||
@@ -471,6 +500,30 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The poll-failure threshold tripped, but the phone's *own* network is down
|
||||||
|
* (state is not [ServerHealth.Healthy]) -- so this is "we can't see the
|
||||||
|
* renderer right now," not "the renderer died." A UPnP renderer streams
|
||||||
|
* autonomously and is almost certainly still playing; meanwhile a local
|
||||||
|
* fallback couldn't reach the server either, so dropping would only blast
|
||||||
|
* local audio out of a pocketed phone while the speaker keeps going. Hold
|
||||||
|
* the route and keep polling -- a successful poll once the network returns
|
||||||
|
* reconciles to the renderer's real state. Clear the failure streak so
|
||||||
|
* recovery re-evaluates the renderer from scratch instead of re-dropping on
|
||||||
|
* the first post-recovery hiccup. Returns the (latched) suppression flag so
|
||||||
|
* the rationale logs once per outage, not every tick.
|
||||||
|
*/
|
||||||
|
private fun suppressDropForNetwork(active: ActiveUpnp, alreadySuppressed: Boolean): Boolean {
|
||||||
|
if (!alreadySuppressed) {
|
||||||
|
Timber.w(
|
||||||
|
"UPnP drop suppressed for %s -- phone network %s, holding route",
|
||||||
|
active.routeName, networkStatus.state.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
remoteState.recordPollSuccess() // clear streak; not a renderer failure
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One poll tick: read position + transport state from Sonos, apply to
|
* One poll tick: read position + transport state from Sonos, apply to
|
||||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
|||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
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 com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -63,6 +65,7 @@ class PlayerController @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
private val radio: RadioController,
|
private val radio: RadioController,
|
||||||
|
private val playlists: PlaylistsRepository,
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
@@ -111,6 +114,13 @@ class PlayerController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private var lastEvaluatedItemIndex: Int = -1
|
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 —
|
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||||
* the player's own getMediaItem(index) returns Media3 types; we keep
|
* the player's own getMediaItem(index) returns Media3 types; we keep
|
||||||
@@ -192,6 +202,20 @@ class PlayerController @Inject constructor(
|
|||||||
controller.play()
|
controller.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Align the local cursor to [index] + [positionMs] WITHOUT auto-playing.
|
||||||
|
* Used by the UPnP adopt path: when attaching to a renderer that's already
|
||||||
|
* playing our queue, we sync the local cursor to the renderer's current
|
||||||
|
* track/position so skip/seek and the UI map onto its live session — the
|
||||||
|
* renderer keeps the audio, the local player stays paused. Unlike
|
||||||
|
* [seekToIndex] this issues no play().
|
||||||
|
*/
|
||||||
|
fun moveCursorTo(index: Int, positionMs: Long) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (index !in queueRefs.indices) return
|
||||||
|
runOnControllerThread(controller) { controller.seekTo(index, positionMs) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the queue with [tracks] starting at [initialIndex].
|
* Replace the queue with [tracks] starting at [initialIndex].
|
||||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
* [source] tags the queue with its origin (e.g. "for_you") so the
|
||||||
@@ -337,7 +361,12 @@ class PlayerController @Inject constructor(
|
|||||||
if (current == null || upnpEngaged) return
|
if (current == null || upnpEngaged) return
|
||||||
val duration = controller.duration
|
val duration = controller.duration
|
||||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
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(
|
playbackErrorEventsChannel.trySend(
|
||||||
PlaybackErrorEvent(
|
PlaybackErrorEvent(
|
||||||
trackId = current.id,
|
trackId = current.id,
|
||||||
@@ -353,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 ──────────
|
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||||
|
|
||||||
private suspend fun connectAndObserve() {
|
private suspend fun connectAndObserve() {
|
||||||
@@ -403,6 +484,17 @@ class PlayerController @Inject constructor(
|
|||||||
detail = error.message,
|
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(
|
override fun onMediaItemTransition(
|
||||||
@@ -749,6 +841,10 @@ class PlayerController @Inject constructor(
|
|||||||
// they don't need this poll.
|
// they don't need this poll.
|
||||||
private const val POSITION_POLL_INTERVAL_MS = 500L
|
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
|
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ class PlayerFactory @Inject constructor(
|
|||||||
delegate = exo,
|
delegate = exo,
|
||||||
holder = activeUpnpHolder,
|
holder = activeUpnpHolder,
|
||||||
remoteState = remoteState,
|
remoteState = remoteState,
|
||||||
|
castNetworkLock = CastNetworkLock(context),
|
||||||
|
networkStatus = serverHealth,
|
||||||
onDrop = { name -> emitDrop(name) },
|
onDrop = { name -> emitDrop(name) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+130
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -172,6 +174,31 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
scope.launch { observeQueueChangesForSonosResync() }
|
scope.launch { observeQueueChangesForSonosResync() }
|
||||||
scope.launch { observeIdleRevertWhileUpnp() }
|
scope.launch { observeIdleRevertWhileUpnp() }
|
||||||
|
scope.launch { observeSelectedRouteDisappearance() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert the UPnP selection to the phone when the selected renderer leaves
|
||||||
|
* discovery (powered off / off the LAN) while we are NOT actively casting
|
||||||
|
* to it. Without this the selection could pin to a ghost route, so a later
|
||||||
|
* "play" would target a speaker that's gone. An *active* cast is left alone
|
||||||
|
* -- its own poll loop arbitrates liveness and won't drop on a mere
|
||||||
|
* discovery gap. Routes leave discovery via [UpnpDiscoveryController]'s
|
||||||
|
* post-picker-scan prune; this reacts to the resulting routes change.
|
||||||
|
*/
|
||||||
|
private suspend fun observeSelectedRouteDisappearance() {
|
||||||
|
upnpDiscovery.routes.collect { routes ->
|
||||||
|
val selected = selectedUpnpRouteIdInternal.value
|
||||||
|
val present = routes.any { it.id == selected }
|
||||||
|
val casting = activeUpnpHolder.active.value?.routeId == selected
|
||||||
|
if (selected != null && !present && !casting) {
|
||||||
|
Timber.w(
|
||||||
|
"Output: selected UPnP route %s left discovery; reverting to phone",
|
||||||
|
selected,
|
||||||
|
)
|
||||||
|
selectedUpnpRouteIdInternal.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -541,6 +568,13 @@ class OutputPickerController @Inject constructor(
|
|||||||
// local audio while we queue up Sonos.
|
// local audio while we queue up Sonos.
|
||||||
playerController.pause()
|
playerController.pause()
|
||||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||||
|
// If the renderer is already playing this exact queue (a "stray"
|
||||||
|
// session we got disconnected from), attach to it in place instead of
|
||||||
|
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
||||||
|
// live queue. Falls through to the clear+reload path when it isn't ours.
|
||||||
|
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||||
// silently while target is set but active is null -- the user's premature
|
// silently while target is set but active is null -- the user's premature
|
||||||
// taps don't hit Sonos's stale state from a prior session.
|
// taps don't hit Sonos's stale state from a prior session.
|
||||||
@@ -567,6 +601,102 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The renderer's current position + transport state, validated as an
|
||||||
|
* adoptable continuation of our local queue: same track id at the same
|
||||||
|
* 1-based index. Null means "not ours / not playing" -> caller reloads.
|
||||||
|
*/
|
||||||
|
private data class AdoptPlan(
|
||||||
|
val localIndex: Int,
|
||||||
|
val positionMs: Long,
|
||||||
|
val durationMs: Long,
|
||||||
|
val trackUri: String,
|
||||||
|
val trackNumber: Int,
|
||||||
|
val playing: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach to a renderer that is *already* playing our queue (a session the
|
||||||
|
* phone got disconnected from but the speaker kept streaming) instead of
|
||||||
|
* clearing + reloading it. Probes the renderer's live state, and if it's
|
||||||
|
* mid-playback on the same track id at the same queue index, syncs the
|
||||||
|
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
|
||||||
|
* skips a beat, and skip/seek now drive its live queue. Returns false (and
|
||||||
|
* the caller does the full clear+reload) when the renderer isn't playing
|
||||||
|
* our queue. The local player is already paused by the caller.
|
||||||
|
*/
|
||||||
|
private suspend fun tryAdoptRunningSession(
|
||||||
|
transport: AVTransportClient,
|
||||||
|
rendering: RenderingControlClient?,
|
||||||
|
route: OutputRoute,
|
||||||
|
localQueue: List<TrackRef>,
|
||||||
|
): Boolean {
|
||||||
|
val plan = probeAdoptable(transport, route, localQueue) ?: return false
|
||||||
|
Timber.w(
|
||||||
|
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
||||||
|
route.name, plan.trackNumber, plan.positionMs,
|
||||||
|
)
|
||||||
|
// active is still null here, so this aligns the *local* cursor without
|
||||||
|
// emitting SOAP; the holder.set below then starts the poll loop, which
|
||||||
|
// reconciles to the renderer's advancing position from here.
|
||||||
|
playerController.moveCursorTo(plan.localIndex, plan.positionMs)
|
||||||
|
remoteState.applyPositionInfo(
|
||||||
|
positionMs = plan.positionMs,
|
||||||
|
durationMs = plan.durationMs,
|
||||||
|
trackUri = plan.trackUri,
|
||||||
|
trackNumber = plan.trackNumber,
|
||||||
|
)
|
||||||
|
remoteState.setPlayIntent(plan.playing)
|
||||||
|
activeUpnpHolder.set(
|
||||||
|
ActiveUpnp(
|
||||||
|
routeId = route.id,
|
||||||
|
routeName = route.name,
|
||||||
|
avTransport = transport,
|
||||||
|
rendering = rendering,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun probeAdoptable(
|
||||||
|
transport: AVTransportClient,
|
||||||
|
route: OutputRoute,
|
||||||
|
localQueue: List<TrackRef>,
|
||||||
|
): AdoptPlan? {
|
||||||
|
val probe = runCatching {
|
||||||
|
transport.getTransportInfo() to transport.getPositionInfo()
|
||||||
|
}.getOrElse {
|
||||||
|
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val (info, pos) = probe
|
||||||
|
val idx0 = pos.track - 1
|
||||||
|
val playingId = trackIdFromStreamUrl(pos.trackUri)
|
||||||
|
val midPlayback =
|
||||||
|
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
||||||
|
val aligned = midPlayback && playingId != null &&
|
||||||
|
idx0 in localQueue.indices && localQueue[idx0].id == playingId
|
||||||
|
return if (aligned) {
|
||||||
|
AdoptPlan(
|
||||||
|
localIndex = idx0,
|
||||||
|
positionMs = pos.relTimeMs,
|
||||||
|
durationMs = pos.trackDurationMs,
|
||||||
|
trackUri = pos.trackUri,
|
||||||
|
trackNumber = pos.track,
|
||||||
|
playing = info.state == TransportState.PLAYING,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
|
||||||
|
private fun trackIdFromStreamUrl(url: String): String? {
|
||||||
|
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
|
||||||
|
val i = segments.indexOf("tracks")
|
||||||
|
return if (i >= 0) segments.getOrNull(i + 1) else null
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadQueueOnSonos(
|
private suspend fun loadQueueOnSonos(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
|
|||||||
+6
-3
@@ -133,7 +133,7 @@ private fun RouteRow(
|
|||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
val subtitle = route.description ?: defaultSubtitle(route)
|
val subtitle = route.description ?: defaultSubtitle(route, isSelected)
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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.BuiltIn -> "Phone speaker"
|
||||||
OutputRoute.Kind.Wired -> "Wired"
|
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.Cast -> "Cast"
|
||||||
OutputRoute.Kind.Other -> "Available"
|
OutputRoute.Kind.Other -> "Available"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ data class OutputRoute(
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val kind: Kind,
|
val kind: Kind,
|
||||||
val protocol: Protocol,
|
val protocol: Protocol,
|
||||||
val isConnected: Boolean,
|
|
||||||
) {
|
) {
|
||||||
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
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
|
* Lift a MediaRouter [route] into the domain model. Kind is
|
||||||
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
||||||
* device types fall through to [Kind.Other]. The
|
* device types fall through to [Kind.Other]. "Active" is NOT an
|
||||||
* `connectionState` proxy is good enough for the chip's
|
* intrinsic of the route — the caller derives it by comparing to
|
||||||
* "Connected"/"Available" subtitle.
|
* 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 {
|
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
||||||
val kind = when (route.deviceType) {
|
val kind = when (route.deviceType) {
|
||||||
@@ -58,15 +59,12 @@ data class OutputRoute(
|
|||||||
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
||||||
else -> Kind.Other
|
else -> Kind.Other
|
||||||
}
|
}
|
||||||
val connected =
|
|
||||||
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
|
|
||||||
return OutputRoute(
|
return OutputRoute(
|
||||||
id = route.id,
|
id = route.id,
|
||||||
name = route.name,
|
name = route.name,
|
||||||
description = route.description,
|
description = route.description,
|
||||||
kind = kind,
|
kind = kind,
|
||||||
protocol = Protocol.SYSTEM,
|
protocol = Protocol.SYSTEM,
|
||||||
isConnected = connected,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +74,10 @@ data class OutputRoute(
|
|||||||
* network speakers into the same `OutputPickerController`
|
* network speakers into the same `OutputPickerController`
|
||||||
* routes stream the system routes come through.
|
* routes stream the system routes come through.
|
||||||
*
|
*
|
||||||
* `isConnected = false` because UPnP devices have no
|
* UPnP devices have no MediaRouter connection-state concept —
|
||||||
* MediaRouter connection-state concept — they're always
|
* they're always "available" on the LAN, and the picker's
|
||||||
* "available" on the LAN, and the picker's selected-route
|
* selected-route rendering handles the "currently playing"
|
||||||
* rendering handles the "currently playing" indicator.
|
* indicator.
|
||||||
*
|
*
|
||||||
* Subtitle is `manufacturer modelName` joined by a single
|
* Subtitle is `manufacturer modelName` joined by a single
|
||||||
* space, falling back to "Network speaker" when both fields
|
* space, falling back to "Network speaker" when both fields
|
||||||
@@ -96,7 +94,6 @@ data class OutputRoute(
|
|||||||
description = description,
|
description = description,
|
||||||
kind = Kind.Other,
|
kind = Kind.Other,
|
||||||
protocol = Protocol.UPNP,
|
protocol = Protocol.UPNP,
|
||||||
isConnected = false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-1
@@ -8,6 +8,7 @@ import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -17,6 +18,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -58,6 +60,12 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||||
|
|
||||||
|
// Wall-clock of the last NOTIFY / M-SEARCH response per route id. Drives
|
||||||
|
// post-active-scan pruning so a powered-off renderer leaves the picker
|
||||||
|
// (and can't pin a stale selection) instead of lingering for the process
|
||||||
|
// lifetime -- upsert only ever added before.
|
||||||
|
private val lastSeenMs = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||||
|
|
||||||
@@ -78,7 +86,31 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
* that just opened).
|
* that just opened).
|
||||||
*/
|
*/
|
||||||
fun upgradeDiscovery() {
|
fun upgradeDiscovery() {
|
||||||
appScope.launch { ssdp.requestActiveScan() }
|
appScope.launch {
|
||||||
|
val scanStartMs = System.currentTimeMillis()
|
||||||
|
ssdp.requestActiveScan()
|
||||||
|
// Give present devices time to answer the M-SEARCH (+ our follow-up
|
||||||
|
// description fetch), then drop any route that didn't re-announce.
|
||||||
|
// Pruning is tied to picker-open scans only -- no background timer,
|
||||||
|
// so route rows never flicker while the user isn't looking.
|
||||||
|
delay(ACTIVE_SCAN_PRUNE_DELAY_MS)
|
||||||
|
pruneRoutesNotSeenSince(scanStartMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop routes whose last NOTIFY / M-SEARCH response predates [cutoffMs]
|
||||||
|
* -- i.e. they didn't answer the active scan that just ran, so they're
|
||||||
|
* gone from the LAN. Called only after [upgradeDiscovery]'s scan window.
|
||||||
|
*/
|
||||||
|
private fun pruneRoutesNotSeenSince(cutoffMs: Long) {
|
||||||
|
val before = routesInternal.value
|
||||||
|
val survivors = before.filter { (lastSeenMs[it.id] ?: 0L) >= cutoffMs }
|
||||||
|
if (survivors.size == before.size) return
|
||||||
|
val removed = before.filterNot { it in survivors }.map { it.id }
|
||||||
|
removed.forEach { lastSeenMs.remove(it) }
|
||||||
|
Timber.w("UPnP discovery: pruned %d stale route(s): %s", removed.size, removed)
|
||||||
|
routesInternal.value = survivors
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,6 +147,7 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun upsertRoute(route: UpnpRoute) {
|
private fun upsertRoute(route: UpnpRoute) {
|
||||||
|
lastSeenMs[route.id] = System.currentTimeMillis()
|
||||||
val current = routesInternal.value
|
val current = routesInternal.value
|
||||||
val idx = current.indexOfFirst { it.id == route.id }
|
val idx = current.indexOfFirst { it.id == route.id }
|
||||||
routesInternal.value = if (idx < 0) {
|
routesInternal.value = if (idx < 0) {
|
||||||
@@ -240,6 +273,11 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
||||||
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
||||||
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
||||||
|
|
||||||
|
// Grace window after an M-SEARCH burst before pruning non-responders.
|
||||||
|
// Covers the SSDP round-trip plus our per-device description fetch;
|
||||||
|
// generous enough that a present-but-slow renderer isn't false-pruned.
|
||||||
|
const val ACTIVE_SCAN_PRUNE_DELAY_MS = 4_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+85
-30
@@ -28,15 +28,17 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
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.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.RefreshCw
|
||||||
import com.composables.icons.lucide.Shuffle
|
import com.composables.icons.lucide.Shuffle
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEvent
|
import com.fabledsword.minstrel.events.LiveEvent
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
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.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
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.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
||||||
@@ -113,6 +118,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -139,6 +145,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
||||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
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>> =
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
likes.observeLikedTrackIds()
|
likes.observeLikedTrackIds()
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -154,6 +169,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { handlePlaylistEvent(it) }
|
.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.
|
* ignored — the playlists-list screen handles those.
|
||||||
*/
|
*/
|
||||||
private fun handlePlaylistEvent(event: LiveEvent) {
|
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
|
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
||||||
if (eventPlaylistId != playlistId) return
|
if (eventPlaylistId != playlistId) return
|
||||||
when (event.kind) {
|
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) {
|
fun toggleLikeTrack(trackId: String) {
|
||||||
val desired = trackId !in likedTrackIds.value
|
val desired = trackId !in likedTrackIds.value
|
||||||
viewModelScope.launch {
|
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). */
|
/** Play the available tracks starting at [startTrackId] (or first available). */
|
||||||
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
||||||
val refs = tracks.toPlayableTrackRefs()
|
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(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(currentTitle(state)) },
|
title = { Text(currentTitle(state)) },
|
||||||
@@ -301,7 +379,11 @@ private fun PlaylistDetailContent(
|
|||||||
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||||
is PlaylistDetailUiState.Loading ->
|
is PlaylistDetailUiState.Loading ->
|
||||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
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 -> {
|
is PlaylistDetailUiState.Success -> {
|
||||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||||
val playerState by playerViewModel.uiState.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 ─────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private const val UNAVAILABLE_ALPHA = 0.4f
|
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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
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.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -47,8 +50,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
@@ -84,6 +89,9 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Copy for the most recent list-refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Live updates: a playlist created/updated/deleted from another
|
// Live updates: a playlist created/updated/deleted from another
|
||||||
@@ -93,11 +101,18 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { refresh() }
|
.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. */
|
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshList() }
|
runCatching { repository.refreshList() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
/** 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>>> =
|
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||||
repository.observeAll()
|
combine(repository.observeAll(), refreshError) { list, err ->
|
||||||
.map { list ->
|
when {
|
||||||
if (list.isEmpty()) {
|
list.isNotEmpty() -> UiState.Success(list)
|
||||||
UiState.Empty
|
// Empty cache + failed refresh is a load failure, not "no
|
||||||
} else {
|
// playlists yet" — surface Error so the user gets a Retry.
|
||||||
UiState.Success(list)
|
err != null -> UiState.Error(err)
|
||||||
}
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +173,10 @@ fun PlaylistsListScreen(
|
|||||||
"appear once your library has enough plays. Create your " +
|
"appear once your library has enough plays. Create your " +
|
||||||
"own playlist from any album or track in the meantime.",
|
"own playlist from any album or track in the meantime.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load playlists",
|
title = "Couldn't load playlists",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> PlaylistsGrid(
|
is UiState.Success -> PlaylistsGrid(
|
||||||
playlists = s.data,
|
playlists = s.data,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
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 " +
|
"and it'll show up here. The track stays out of system " +
|
||||||
"playlists until you unhide it.",
|
"playlists until you unhide it.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load hidden tracks",
|
title = "Couldn't load hidden tracks",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HiddenList(
|
is UiState.Success -> HiddenList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.quarantine.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
@@ -24,6 +26,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HiddenTabViewModel @Inject constructor(
|
class HiddenTabViewModel @Inject constructor(
|
||||||
private val repository: QuarantineRepository,
|
private val repository: QuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -61,6 +64,11 @@ class HiddenTabViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.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 {
|
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.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -78,9 +79,10 @@ fun RequestsScreen(
|
|||||||
body = "Use Discover to ask Lidarr for new music; your " +
|
body = "Use Discover to ask Lidarr for new music; your " +
|
||||||
"requests show up here.",
|
"requests show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> RequestList(
|
is UiState.Success -> RequestList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
+16
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.requests.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.models.RequestStatus
|
import com.fabledsword.minstrel.models.RequestStatus
|
||||||
@@ -30,6 +32,7 @@ private const val POLL_INTERVAL_MS = 12_000L
|
|||||||
class RequestsViewModel @Inject constructor(
|
class RequestsViewModel @Inject constructor(
|
||||||
private val repository: RequestsRepository,
|
private val repository: RequestsRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
||||||
@@ -45,6 +48,14 @@ class RequestsViewModel @Inject constructor(
|
|||||||
.collect { silentReload() }
|
.collect { silentReload() }
|
||||||
}
|
}
|
||||||
viewModelScope.launch { pollWhileInFlight() }
|
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 {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -71,7 +82,11 @@ class RequestsViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private suspend fun pollWhileInFlight() {
|
private suspend fun pollWhileInFlight() {
|
||||||
while (true) {
|
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()
|
silentReload()
|
||||||
}
|
}
|
||||||
delay(POLL_INTERVAL_MS)
|
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.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
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.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -113,6 +114,7 @@ fun SearchScreen(
|
|||||||
onTrackPlay = viewModel::playTrack,
|
onTrackPlay = viewModel::playTrack,
|
||||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onRetry = viewModel::retry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,11 +153,16 @@ private fun ResultsPane(
|
|||||||
onTrackPlay: (TrackRef) -> Unit,
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
onNavigateToAlbum: (String) -> Unit,
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
onNavigateToArtist: (String) -> Unit,
|
onNavigateToArtist: (String) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||||
SearchResultsState.Loading -> LoadingCentered()
|
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 -> {
|
is SearchResultsState.Loaded -> {
|
||||||
if (state.response.isEmpty) {
|
if (state.response.isEmpty) {
|
||||||
CenteredHint(
|
CenteredHint(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.search.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
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.SearchResponseRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -43,6 +45,7 @@ data class SearchState(
|
|||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
private val repository: SearchRepository,
|
private val repository: SearchRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val queryFlow = MutableStateFlow("")
|
private val queryFlow = MutableStateFlow("")
|
||||||
@@ -69,6 +72,14 @@ class SearchViewModel @Inject constructor(
|
|||||||
runSearch(q)
|
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) {
|
fun setQuery(value: String) {
|
||||||
@@ -80,6 +91,18 @@ class SearchViewModel @Inject constructor(
|
|||||||
setQuery("")
|
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
|
* Tapping a search result builds a queue from the full visible
|
||||||
* track-results list starting at the tapped entry, matching Flutter.
|
* 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.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -94,6 +95,7 @@ fun SettingsScreen(
|
|||||||
onPickTheme = themeVm::setThemeMode,
|
onPickTheme = themeVm::setThemeMode,
|
||||||
onNavToRequests = { navController.navigate(Requests) },
|
onNavToRequests = { navController.navigate(Requests) },
|
||||||
onNavToAdmin = { navController.navigate(Admin) },
|
onNavToAdmin = { navController.navigate(Admin) },
|
||||||
|
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
||||||
onSignOutClick = { showSignOutConfirm = true },
|
onSignOutClick = { showSignOutConfirm = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -116,6 +118,7 @@ private fun SettingsList(
|
|||||||
onPickTheme: (ThemeMode) -> Unit,
|
onPickTheme: (ThemeMode) -> Unit,
|
||||||
onNavToRequests: () -> Unit,
|
onNavToRequests: () -> Unit,
|
||||||
onNavToAdmin: () -> Unit,
|
onNavToAdmin: () -> Unit,
|
||||||
|
onToggleDiagnostics: (Boolean) -> Unit,
|
||||||
onSignOutClick: () -> Unit,
|
onSignOutClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -148,6 +151,12 @@ private fun SettingsList(
|
|||||||
ProfileCard()
|
ProfileCard()
|
||||||
PasswordCard()
|
PasswordCard()
|
||||||
ListenBrainzCard()
|
ListenBrainzCard()
|
||||||
|
if (state.diagnosticsEnabledByAdmin) {
|
||||||
|
DiagnosticsCard(
|
||||||
|
optOut = state.diagnosticsOptOut,
|
||||||
|
onToggle = onToggleDiagnostics,
|
||||||
|
)
|
||||||
|
}
|
||||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||||
StorageCard()
|
StorageCard()
|
||||||
AboutCard()
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
||||||
|
|||||||
+13
-2
@@ -19,12 +19,15 @@ data class SettingsState(
|
|||||||
val isAdmin: Boolean = false,
|
val isAdmin: Boolean = false,
|
||||||
val isSigningOut: Boolean = false,
|
val isSigningOut: Boolean = false,
|
||||||
val signedOut: 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
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val authController: AuthController,
|
private val authController: AuthController,
|
||||||
authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val transient = MutableStateFlow(TransientState())
|
private val transient = MutableStateFlow(TransientState())
|
||||||
@@ -41,19 +44,27 @@ class SettingsViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
authStore.baseUrl,
|
authStore.baseUrl,
|
||||||
authController.currentUser,
|
authController.currentUser,
|
||||||
|
authStore.diagnosticsOptOut,
|
||||||
transient,
|
transient,
|
||||||
) { url, user, t ->
|
) { url, user, optOut, t ->
|
||||||
SettingsState(
|
SettingsState(
|
||||||
serverUrl = url,
|
serverUrl = url,
|
||||||
username = user?.username.orEmpty(),
|
username = user?.username.orEmpty(),
|
||||||
isAdmin = user?.isAdmin == true,
|
isAdmin = user?.isAdmin == true,
|
||||||
isSigningOut = t.isSigningOut,
|
isSigningOut = t.isSigningOut,
|
||||||
signedOut = t.signedOut,
|
signedOut = t.signedOut,
|
||||||
|
diagnosticsEnabledByAdmin = user?.debugModeEnabled == true,
|
||||||
|
diagnosticsOptOut = optOut,
|
||||||
)
|
)
|
||||||
}.collect { internal.value = it }
|
}.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() {
|
fun signOut() {
|
||||||
if (transient.value.isSigningOut) return
|
if (transient.value.isSigningOut) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
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
|
* Shared error-with-retry widget. Surfaces a brief message and a
|
||||||
* primary action button. Per the design system, the button uses
|
* primary action button. Per the design system, the button uses
|
||||||
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
* `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
|
@Composable
|
||||||
fun ErrorRetry(
|
fun ErrorRetry(
|
||||||
message: String,
|
message: String,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
title: String? = null,
|
||||||
retryLabel: String = "Retry",
|
retryLabel: String = "Retry",
|
||||||
) {
|
) {
|
||||||
val actionColors = LocalActionColors.current
|
val actionColors = LocalActionColors.current
|
||||||
Column(
|
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||||
modifier = modifier
|
item {
|
||||||
.fillMaxSize()
|
Column(
|
||||||
.padding(32.dp),
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.Center,
|
.fillParentMaxSize()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.padding(32.dp),
|
||||||
) {
|
verticalArrangement = Arrangement.Center,
|
||||||
Icon(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
imageVector = Lucide.TriangleAlert,
|
) {
|
||||||
contentDescription = null,
|
Icon(
|
||||||
modifier = Modifier.size(48.dp),
|
imageVector = Lucide.TriangleAlert,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.size(48.dp),
|
||||||
Text(
|
tint = MaterialTheme.colorScheme.error,
|
||||||
text = message,
|
)
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
if (title != null) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
text = title,
|
||||||
textAlign = TextAlign.Center,
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
)
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Button(
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
onClick = onRetry,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
)
|
||||||
colors = ButtonDefaults.buttonColors(
|
}
|
||||||
containerColor = actionColors.primary,
|
Text(
|
||||||
contentColor = actionColors.onAction,
|
text = message,
|
||||||
),
|
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
|
||||||
) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Text(retryLabel)
|
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
|
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.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.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
* 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
|
@Composable
|
||||||
fun ServerImage(
|
fun ServerImage(
|
||||||
@@ -22,12 +32,26 @@ fun ServerImage(
|
|||||||
val resolved = resolveServerUrl(url)
|
val resolved = resolveServerUrl(url)
|
||||||
if (resolved == null) {
|
if (resolved == null) {
|
||||||
fallback()
|
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(
|
AsyncImage(
|
||||||
model = resolved,
|
model = resolved,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = contentScale,
|
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.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -21,6 +22,13 @@ import kotlin.test.assertEquals
|
|||||||
@ExtendWith(MainDispatcherExtension::class)
|
@ExtendWith(MainDispatcherExtension::class)
|
||||||
class LibraryViewModelTest {
|
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
|
@Test
|
||||||
fun `initial state is Loading before any DAO emission`() = runTest {
|
fun `initial state is Loading before any DAO emission`() = runTest {
|
||||||
val repo = mockk<LibraryRepository>()
|
val repo = mockk<LibraryRepository>()
|
||||||
@@ -29,7 +37,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +59,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,7 +79,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+18
-8
@@ -6,17 +6,27 @@ in-app update flow (#397).
|
|||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|
||||||
CI populates this directory on tag releases:
|
CI populates this directory in `release.yml` (no Flutter — the client is
|
||||||
1. `flutter.yml` builds `app-release.apk` and attaches it to the
|
native Android, built by the `android-release` job):
|
||||||
Gitea release as `minstrel-<TAG>.apk`.
|
|
||||||
2. `release.yml` waits for that asset to appear, downloads it into
|
- **Tag release (`v*`):** the `android-release` job builds + signs the
|
||||||
this directory as `minstrel.apk`, writes the tag string to
|
APK, uploads it as a workflow artifact, and attaches it to the Gitea
|
||||||
`minstrel.apk.version`, and `docker buildx build` includes both
|
release as `minstrel-<TAG>.apk`. The `image-release` job
|
||||||
via `COPY client/ /app/client/`.
|
(`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
|
## 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).
|
update banner stays hidden ("no update channel" graceful degradation).
|
||||||
|
|
||||||
To smoke-test the update flow locally, drop a real APK + a version
|
To smoke-test the update flow locally, drop a real APK + a version
|
||||||
|
|||||||
+18
-1
@@ -25,6 +25,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||||||
@@ -146,6 +147,16 @@ func run() error {
|
|||||||
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
||||||
coverEnricher.DataDir = cfg.Storage.DataDir
|
coverEnricher.DataDir = cfg.Storage.DataDir
|
||||||
|
|
||||||
|
// Recommendation tuning lab (#1250): seeds shipped defaults on first
|
||||||
|
// boot and pushes the daily_mix weights + taste config into package
|
||||||
|
// playlists — must precede the scheduler so the first builds score
|
||||||
|
// with the operator's tuned values, not the pre-push literals.
|
||||||
|
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("recommendation settings service init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
||||||
// Boot-time scan and manual-trigger scans share this path; results land
|
// Boot-time scan and manual-trigger scans share this path; results land
|
||||||
// in scan_runs for the admin overview.
|
// in scan_runs for the admin overview.
|
||||||
@@ -242,7 +253,12 @@ func run() error {
|
|||||||
// active user's daily build at 03:00 in their stored timezone.
|
// active user's daily build at 03:00 in their stored timezone.
|
||||||
// Replaces the 24h-anchored cron loop (removed in the next commit
|
// Replaces the 24h-anchored cron loop (removed in the next commit
|
||||||
// of this arc).
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("init playlist scheduler: %w", err)
|
return fmt.Errorf("init playlist scheduler: %w", err)
|
||||||
}
|
}
|
||||||
@@ -289,6 +305,7 @@ func run() error {
|
|||||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||||||
srv.Bus = bus
|
srv.Bus = bus
|
||||||
srv.PlaylistScheduler = playlistScheduler
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
|
srv.RecSettings = recSettings
|
||||||
srv.StreamSecret = cfg.StreamSecret
|
srv.StreamSecret = cfg.StreamSecret
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
|
|||||||
+7
-2
@@ -26,8 +26,13 @@ library:
|
|||||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||||
scan_paths: []
|
scan_paths: []
|
||||||
# Kick off a scan on server startup. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
|
# Kick off a scan on server startup. On by default so a fresh install
|
||||||
scan_on_startup: false
|
# 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
|
# Per-instance branding. Operators can rename their instance ("Family
|
||||||
# Jukebox", "Office Music", etc.) and override the OG share-preview
|
# Jukebox", "Office Music", etc.) and override the OG share-preview
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# README screenshots
|
||||||
|
|
||||||
|
Drop the files below into this directory with these **exact names** — the
|
||||||
|
root `README.md` already references them, so no Markdown editing is needed.
|
||||||
|
Export as PNG. Capture while logged in as admin and *after* the first
|
||||||
|
library scan so nothing looks empty; keep the same browser width and theme
|
||||||
|
across all shots for a consistent look.
|
||||||
|
|
||||||
|
| Filename | Route | What to capture |
|
||||||
|
|---|---|---|
|
||||||
|
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
|
||||||
|
| `register.png` | `/register` | The account-creation form on a fresh instance |
|
||||||
|
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
|
||||||
|
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
|
||||||
|
| `android-download.png` | `/settings` | The "Install the Android app" download card |
|
||||||
|
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
|
||||||
|
|
||||||
|
Tip: to control display width in the README, you can swap the Markdown
|
||||||
|
`` for an HTML tag, e.g. `<img src="docs/screenshots/home.png" width="800" alt="...">`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,202 @@
|
|||||||
|
// Admin recommendation-trends endpoint (#1251): weekly per-surface
|
||||||
|
// outcome series with tuning-audit markers — the verify half of the
|
||||||
|
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
|
||||||
|
// all users because the knobs are global; rows carry rates only.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
trendsDefaultWeeks = 12
|
||||||
|
trendsMaxWeeks = 52
|
||||||
|
// trendsAuditFetchCap bounds the audit fetch; markers older than
|
||||||
|
// the window are dropped in Go. Far above any real knob-turn count
|
||||||
|
// inside a year.
|
||||||
|
trendsAuditFetchCap = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// trendPoint is one week of one surface family's outcomes.
|
||||||
|
type trendPoint struct {
|
||||||
|
WeekStart string `json:"week_start"` // ISO date (Monday)
|
||||||
|
Plays int64 `json:"plays"`
|
||||||
|
Skips int64 `json:"skips"`
|
||||||
|
SkipRate float64 `json:"skip_rate"`
|
||||||
|
AvgCompletion float64 `json:"avg_completion"`
|
||||||
|
// TasteHitRate is the share of plays whose track's artist carries a
|
||||||
|
// positive weight in the player's current taste profile — a drifted
|
||||||
|
// but retroactive read on whether the surface feeds taste-fitting
|
||||||
|
// tracks.
|
||||||
|
TasteHitRate float64 `json:"taste_hit_rate"`
|
||||||
|
// completionN carries the completion sample count through same-week
|
||||||
|
// merges so avg_completion stays sample-weighted; not serialized.
|
||||||
|
completionN int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// trendSeries is one surface family's weekly series.
|
||||||
|
type trendSeries struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
|
||||||
|
Plays int64 `json:"plays"` // window total, for sorting/volume-gating
|
||||||
|
Points []trendPoint `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// trendMarker is one tuning-audit event annotated on the timeline.
|
||||||
|
type trendMarker struct {
|
||||||
|
ChangedAt string `json:"changed_at"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Changes json.RawMessage `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type trendsResp struct {
|
||||||
|
Weeks int `json:"weeks"`
|
||||||
|
Series []trendSeries `json:"series"`
|
||||||
|
Markers []trendMarker `json:"markers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationTrends implements
|
||||||
|
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
|
||||||
|
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
|
||||||
|
weeks := trendsDefaultWeeks
|
||||||
|
if v := r.URL.Query().Get("weeks"); v != "" {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > trendsMaxWeeks {
|
||||||
|
n = trendsMaxWeeks
|
||||||
|
}
|
||||||
|
weeks = n
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: recommendation trends", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: recommendation trends audit", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTrendsResponse folds weekly rows into per-family series and
|
||||||
|
// windows the audit markers. Split from the handler for pure-unit
|
||||||
|
// testability.
|
||||||
|
func buildTrendsResponse(
|
||||||
|
weeks int, now time.Time,
|
||||||
|
rows []dbq.RecommendationWeeklyTrendsRow,
|
||||||
|
audits []dbq.RecommendationTuningAudit,
|
||||||
|
) trendsResp {
|
||||||
|
type accum struct {
|
||||||
|
fam recFamily
|
||||||
|
plays int64
|
||||||
|
points []trendPoint
|
||||||
|
}
|
||||||
|
families := map[string]*accum{}
|
||||||
|
for _, row := range rows {
|
||||||
|
fam := recFamily{key: "manual", label: "Manual library plays"}
|
||||||
|
if row.Source != nil && *row.Source != "" {
|
||||||
|
fam = bucketRecSource(*row.Source)
|
||||||
|
}
|
||||||
|
acc, ok := families[fam.key]
|
||||||
|
if !ok {
|
||||||
|
acc = &accum{fam: fam}
|
||||||
|
families[fam.key] = acc
|
||||||
|
}
|
||||||
|
acc.plays += row.Plays
|
||||||
|
p := trendPoint{
|
||||||
|
WeekStart: row.WeekStart.Time.Format("2006-01-02"),
|
||||||
|
Plays: row.Plays,
|
||||||
|
Skips: row.Skips,
|
||||||
|
AvgCompletion: row.AvgCompletion,
|
||||||
|
completionN: row.CompletionN,
|
||||||
|
}
|
||||||
|
if row.Plays > 0 {
|
||||||
|
p.SkipRate = float64(row.Skips) / float64(row.Plays)
|
||||||
|
p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
|
||||||
|
}
|
||||||
|
// Same family can arrive as several raw sources (radio:<uuid>);
|
||||||
|
// merge same-week points play-weighted.
|
||||||
|
if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
|
||||||
|
acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
|
||||||
|
} else {
|
||||||
|
acc.points = append(acc.points, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
|
||||||
|
for _, acc := range families {
|
||||||
|
resp.Series = append(resp.Series, trendSeries{
|
||||||
|
Key: acc.fam.key,
|
||||||
|
Label: acc.fam.label,
|
||||||
|
Intent: acc.fam.intent,
|
||||||
|
Plays: acc.plays,
|
||||||
|
Points: acc.points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(resp.Series, func(i, j int) bool {
|
||||||
|
if resp.Series[i].Plays != resp.Series[j].Plays {
|
||||||
|
return resp.Series[i].Plays > resp.Series[j].Plays
|
||||||
|
}
|
||||||
|
return resp.Series[i].Key < resp.Series[j].Key
|
||||||
|
})
|
||||||
|
|
||||||
|
cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
|
||||||
|
for _, a := range audits {
|
||||||
|
if a.ChangedAt.Time.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Markers = append(resp.Markers, trendMarker{
|
||||||
|
ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
|
||||||
|
Scope: a.Scope,
|
||||||
|
Action: a.Action,
|
||||||
|
Changes: json.RawMessage(a.Changes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// ListTuningAudit returns newest-first; the timeline reads better
|
||||||
|
// oldest-first.
|
||||||
|
sort.Slice(resp.Markers, func(i, j int) bool {
|
||||||
|
return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
|
||||||
|
})
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeTrendPoints combines two same-week points of one family:
|
||||||
|
// counts add, skip/taste rates re-derive from the merged counts, and
|
||||||
|
// avg_completion is weighted by each side's completion sample count.
|
||||||
|
func mergeTrendPoints(a, b trendPoint) trendPoint {
|
||||||
|
out := trendPoint{
|
||||||
|
WeekStart: a.WeekStart,
|
||||||
|
Plays: a.Plays + b.Plays,
|
||||||
|
Skips: a.Skips + b.Skips,
|
||||||
|
completionN: a.completionN + b.completionN,
|
||||||
|
}
|
||||||
|
if out.Plays > 0 {
|
||||||
|
out.SkipRate = float64(out.Skips) / float64(out.Plays)
|
||||||
|
out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
|
||||||
|
float64(out.Plays)
|
||||||
|
}
|
||||||
|
if out.completionN > 0 {
|
||||||
|
out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
|
||||||
|
float64(out.completionN)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func date(s string) pgtype.Date {
|
||||||
|
t, _ := time.Parse("2006-01-02", s)
|
||||||
|
return pgtype.Date{Time: t, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTrendsResponse_SeriesMergingAndMarkers(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
rows := []dbq.RecommendationWeeklyTrendsRow{
|
||||||
|
// Two radio session sources in the same week collapse into one
|
||||||
|
// family point; completion weighting by sample count.
|
||||||
|
{WeekStart: date("2026-06-22"), Source: src("radio:aaaa"),
|
||||||
|
Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 0.6, TasteHits: 3},
|
||||||
|
{WeekStart: date("2026-06-22"), Source: src("radio:bbbb"),
|
||||||
|
Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2, TasteHits: 0},
|
||||||
|
{WeekStart: date("2026-06-29"), Source: src("radio:aaaa"),
|
||||||
|
Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9, TasteHits: 1},
|
||||||
|
// NULL source = manual baseline family.
|
||||||
|
{WeekStart: date("2026-06-29"), Source: nil,
|
||||||
|
Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.8, TasteHits: 4},
|
||||||
|
}
|
||||||
|
audits := []dbq.RecommendationTuningAudit{
|
||||||
|
{ID: 2, ChangedAt: pgtype.Timestamptz{Time: now.Add(-24 * time.Hour), Valid: true},
|
||||||
|
Scope: "radio", Action: "update", Changes: []byte(`[{"field":"taste_weight","old":1,"new":2}]`)},
|
||||||
|
// Older than the window → dropped.
|
||||||
|
{ID: 1, ChangedAt: pgtype.Timestamptz{Time: now.Add(-100 * 7 * 24 * time.Hour), Valid: true},
|
||||||
|
Scope: "taste", Action: "reset", Changes: []byte(`[]`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildTrendsResponse(12, now, rows, audits)
|
||||||
|
|
||||||
|
if len(resp.Series) != 2 {
|
||||||
|
t.Fatalf("series = %d, want 2 (radio + manual)", len(resp.Series))
|
||||||
|
}
|
||||||
|
radio := resp.Series[0] // 6 plays > manual's 5 → sorted first
|
||||||
|
if radio.Key != "radio" || radio.Plays != 6 {
|
||||||
|
t.Fatalf("series[0] = %s/%d, want radio/6", radio.Key, radio.Plays)
|
||||||
|
}
|
||||||
|
if len(radio.Points) != 2 {
|
||||||
|
t.Fatalf("radio points = %d, want 2 weeks", len(radio.Points))
|
||||||
|
}
|
||||||
|
wk1 := radio.Points[0]
|
||||||
|
if wk1.WeekStart != "2026-06-22" || wk1.Plays != 4 || wk1.Skips != 2 {
|
||||||
|
t.Errorf("week1 = %+v, want 2026-06-22 with 4 plays / 2 skips", wk1)
|
||||||
|
}
|
||||||
|
if wk1.SkipRate != 0.5 {
|
||||||
|
t.Errorf("week1 skip_rate = %v, want 0.5", wk1.SkipRate)
|
||||||
|
}
|
||||||
|
// Completion weighted by sample count: (0.6*3 + 0.2*1) / 4 = 0.5.
|
||||||
|
if wk1.AvgCompletion < 0.49 || wk1.AvgCompletion > 0.51 {
|
||||||
|
t.Errorf("week1 avg_completion = %v, want 0.5", wk1.AvgCompletion)
|
||||||
|
}
|
||||||
|
// Taste hits: 3 of 4 plays.
|
||||||
|
if wk1.TasteHitRate != 0.75 {
|
||||||
|
t.Errorf("week1 taste_hit_rate = %v, want 0.75", wk1.TasteHitRate)
|
||||||
|
}
|
||||||
|
if manual := resp.Series[1]; manual.Key != "manual" || manual.Intent != "" {
|
||||||
|
t.Errorf("series[1] = %+v, want the manual baseline family", manual)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Markers) != 1 {
|
||||||
|
t.Fatalf("markers = %d, want 1 (out-of-window marker dropped)", len(resp.Markers))
|
||||||
|
}
|
||||||
|
if resp.Markers[0].Scope != "radio" || resp.Markers[0].Action != "update" {
|
||||||
|
t.Errorf("marker = %+v, want radio/update", resp.Markers[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTrendsRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/admin/recommendation-trends", h.handleGetRecommendationTrends)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTrends_EndToEnd(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
user := seedUser(t, pool, "trends", "pw", true)
|
||||||
|
artist := seedArtist(t, pool, "TrendArtist")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "TrendAlbum", 2020)
|
||||||
|
tk := seedTrack(t, pool, album.ID, artist.ID, "TrendTrack", 1, 200000)
|
||||||
|
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||||
|
|
||||||
|
// Positive taste weight for the artist → plays count as taste hits.
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 1.5)`,
|
||||||
|
user.ID, artist.ID); err != nil {
|
||||||
|
t.Fatalf("seed taste profile: %v", err)
|
||||||
|
}
|
||||||
|
radio := "radio"
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radio, nil, 0.9, false)
|
||||||
|
// A knob turn to mark the timeline.
|
||||||
|
if err := h.recSettings.UpdateProfile(context.Background(), "radio",
|
||||||
|
map[string]float64{"taste_weight": 2}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTrendsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp trendsResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Weeks != trendsDefaultWeeks {
|
||||||
|
t.Errorf("weeks = %d, want %d", resp.Weeks, trendsDefaultWeeks)
|
||||||
|
}
|
||||||
|
var radioSeries *trendSeries
|
||||||
|
for i := range resp.Series {
|
||||||
|
if resp.Series[i].Key == "radio" {
|
||||||
|
radioSeries = &resp.Series[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if radioSeries == nil || len(radioSeries.Points) != 1 {
|
||||||
|
t.Fatalf("radio series = %+v, want one point", radioSeries)
|
||||||
|
}
|
||||||
|
if radioSeries.Points[0].TasteHitRate != 1.0 {
|
||||||
|
t.Errorf("taste_hit_rate = %v, want 1.0 (positive-weight artist)",
|
||||||
|
radioSeries.Points[0].TasteHitRate)
|
||||||
|
}
|
||||||
|
if len(resp.Markers) != 1 || resp.Markers[0].Scope != "radio" {
|
||||||
|
t.Errorf("markers = %+v, want the radio knob turn", resp.Markers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTrends_InvalidWeeks(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends?weeks=zero", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTrendsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Admin recommendation-tuning endpoints (#1250): the defaults-
|
||||||
|
// discovery lab. GET returns current values + shipped defaults for
|
||||||
|
// every scope; PATCH applies a partial update to one scope; reset
|
||||||
|
// restores a scope to shipped defaults. Every change writes an audit
|
||||||
|
// row (consumed by the metrics trend view, #1251).
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// weightsResp is one weight profile on the wire, keyed by the same
|
||||||
|
// snake_case field names the PATCH body accepts.
|
||||||
|
type weightsResp struct {
|
||||||
|
BaseWeight float64 `json:"base_weight"`
|
||||||
|
LikeBoost float64 `json:"like_boost"`
|
||||||
|
RecencyWeight float64 `json:"recency_weight"`
|
||||||
|
SkipPenalty float64 `json:"skip_penalty"`
|
||||||
|
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||||
|
ContextWeight float64 `json:"context_weight"`
|
||||||
|
SimilarityWeight float64 `json:"similarity_weight"`
|
||||||
|
TasteWeight float64 `json:"taste_weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
||||||
|
return weightsResp{
|
||||||
|
BaseWeight: w.BaseWeight,
|
||||||
|
LikeBoost: w.LikeBoost,
|
||||||
|
RecencyWeight: w.RecencyWeight,
|
||||||
|
SkipPenalty: w.SkipPenalty,
|
||||||
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
|
ContextWeight: w.ContextWeight,
|
||||||
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
|
TasteWeight: w.TasteWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tasteTuningResp struct {
|
||||||
|
HalfLifeDays float64 `json:"half_life_days"`
|
||||||
|
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||||
|
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||||
|
EngagementFull float64 `json:"engagement_full"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||||
|
return tasteTuningResp{
|
||||||
|
HalfLifeDays: t.HalfLifeDays,
|
||||||
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
|
EngagementFull: t.EngagementFull,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuningSnapshot is both the GET response and the post-mutation echo:
|
||||||
|
// current values alongside shipped defaults so the card can mark
|
||||||
|
// which knobs deviate.
|
||||||
|
type tuningSnapshot struct {
|
||||||
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Shipped struct {
|
||||||
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
} `json:"shipped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) tuningSnapshot() tuningSnapshot {
|
||||||
|
var out tuningSnapshot
|
||||||
|
out.Profiles = map[string]weightsResp{
|
||||||
|
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
|
||||||
|
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||||
|
}
|
||||||
|
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||||
|
out.Shipped.Profiles = map[string]weightsResp{
|
||||||
|
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||||
|
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||||
|
}
|
||||||
|
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
|
||||||
|
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchTuningReq carries the partial update: field name → new value,
|
||||||
|
// using the same snake_case names the GET response emits.
|
||||||
|
type patchTuningReq struct {
|
||||||
|
Values map[string]float64 `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePatchRecommendationTuning implements
|
||||||
|
// PATCH /api/admin/recommendation-tuning/{scope}.
|
||||||
|
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := chi.URLParam(r, "scope")
|
||||||
|
var body patchTuningReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Values) == 0 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if scope == recsettings.ScopeTaste {
|
||||||
|
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
||||||
|
} else {
|
||||||
|
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeTuningErr(w, h, scope, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetRecommendationTuning implements
|
||||||
|
// POST /api/admin/recommendation-tuning/{scope}/reset.
|
||||||
|
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := chi.URLParam(r, "scope")
|
||||||
|
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
|
||||||
|
writeTuningErr(w, h, scope, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTuningErr maps recsettings validation errors to 400s and
|
||||||
|
// everything else to a logged 500.
|
||||||
|
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, recsettings.ErrUnknownScope):
|
||||||
|
writeErr(w, &apierror.Error{
|
||||||
|
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
|
||||||
|
})
|
||||||
|
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
|
||||||
|
default:
|
||||||
|
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("tuning update failed", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTuningRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||||
|
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||||
|
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
|
||||||
|
t.Helper()
|
||||||
|
var snap tuningSnapshot
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTuningRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
snap := decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
|
||||||
|
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
|
||||||
|
}
|
||||||
|
if snap.Taste.HalfLifeDays != 75 {
|
||||||
|
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
|
||||||
|
}
|
||||||
|
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
|
||||||
|
t.Error("untouched values must equal shipped defaults")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
r := newTuningRouter(h)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
|
||||||
|
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
snap := decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 2.5 {
|
||||||
|
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
|
||||||
|
}
|
||||||
|
// The change is live for the radio scoring path.
|
||||||
|
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
|
||||||
|
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("reset status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
snap = decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 1.0 {
|
||||||
|
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_PatchErrors(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
r := newTuningRouter(h)
|
||||||
|
cases := []struct {
|
||||||
|
name, path, body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"unknown scope", "/api/admin/recommendation-tuning/banana",
|
||||||
|
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
|
||||||
|
{"unknown field", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{"values":{"vibes":1}}`, http.StatusBadRequest},
|
||||||
|
{"out of range", "/api/admin/recommendation-tuning/taste",
|
||||||
|
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
|
||||||
|
{"empty values", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{"values":{}}`, http.StatusBadRequest},
|
||||||
|
{"bad json", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{`, http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != c.want {
|
||||||
|
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ type adminUserView struct {
|
|||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
AutoApproveRequests bool `json:"auto_approve_requests"`
|
AutoApproveRequests bool `json:"auto_approve_requests"`
|
||||||
|
DebugModeEnabled bool `json:"debug_mode_enabled"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ func (h *handlers) handleAdminListUsers(w http.ResponseWriter, r *http.Request)
|
|||||||
DisplayName: row.DisplayName,
|
DisplayName: row.DisplayName,
|
||||||
IsAdmin: row.IsAdmin,
|
IsAdmin: row.IsAdmin,
|
||||||
AutoApproveRequests: row.AutoApproveRequests,
|
AutoApproveRequests: row.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: row.DebugModeEnabled,
|
||||||
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
|
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
@@ -120,6 +122,7 @@ func (h *handlers) handleUpdateUserAdmin(w http.ResponseWriter, r *http.Request)
|
|||||||
DisplayName: updated.DisplayName,
|
DisplayName: updated.DisplayName,
|
||||||
IsAdmin: updated.IsAdmin,
|
IsAdmin: updated.IsAdmin,
|
||||||
AutoApproveRequests: updated.AutoApproveRequests,
|
AutoApproveRequests: updated.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: updated.DebugModeEnabled,
|
||||||
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
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,
|
DisplayName: user.DisplayName,
|
||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
AutoApproveRequests: user.AutoApproveRequests,
|
AutoApproveRequests: user.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: user.DebugModeEnabled,
|
||||||
CreatedAt: user.CreatedAt.Time.UTC().Format(time.RFC3339),
|
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,
|
DisplayName: updated.DisplayName,
|
||||||
IsAdmin: updated.IsAdmin,
|
IsAdmin: updated.IsAdmin,
|
||||||
AutoApproveRequests: updated.AutoApproveRequests,
|
AutoApproveRequests: updated.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: updated.DebugModeEnabled,
|
||||||
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -22,16 +22,18 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
|
recSettings: recSettings,
|
||||||
rng: rng.Float64,
|
rng: rng.Float64,
|
||||||
lidarrCfg: lidarrCfg,
|
lidarrCfg: lidarrCfg,
|
||||||
lidarrRequests: lidarrReqs,
|
lidarrRequests: lidarrReqs,
|
||||||
@@ -74,6 +76,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Post("/auth/logout", h.handleLogout)
|
authed.Post("/auth/logout", h.handleLogout)
|
||||||
authed.Get("/me", h.handleGetMe)
|
authed.Get("/me", h.handleGetMe)
|
||||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
||||||
|
authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||||
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
||||||
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
||||||
authed.Get("/me/history", h.handleGetMyHistory)
|
authed.Get("/me/history", h.handleGetMyHistory)
|
||||||
@@ -134,6 +137,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
// load failures). Admin-only inbox; any user can report.
|
// load failures). Admin-only inbox; any user can report.
|
||||||
authed.Post("/playback-errors", h.handleReportPlaybackError)
|
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
|
// Self-hosted in-app update channel (#397). Auth-gated to
|
||||||
// prevent anonymous bandwidth abuse on the APK stream;
|
// prevent anonymous bandwidth abuse on the APK stream;
|
||||||
// /apk additionally per-user rate-limited.
|
// /apk additionally per-user rate-limited.
|
||||||
@@ -180,6 +189,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Delete("/users/{id}", h.handleAdminDeleteUser)
|
admin.Delete("/users/{id}", h.handleAdminDeleteUser)
|
||||||
admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword)
|
admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword)
|
||||||
admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle)
|
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.Get("/cover-sources", h.handleListCoverSources)
|
||||||
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
||||||
@@ -189,6 +203,14 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||||
|
|
||||||
|
// Recommendation tuning lab (#1250): scoring-weight
|
||||||
|
// profiles + taste-build knobs, DB-backed, live effect.
|
||||||
|
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||||
|
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||||
|
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||||
|
// Weekly outcome trends + knob-turn markers (#1251).
|
||||||
|
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
|
||||||
})
|
})
|
||||||
|
|
||||||
authed.Get("/playlists", h.handleListPlaylists)
|
authed.Get("/playlists", h.handleListPlaylists)
|
||||||
@@ -211,6 +233,7 @@ type handlers struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
events *playevents.Writer
|
events *playevents.Writer
|
||||||
recCfg config.RecommendationConfig
|
recCfg config.RecommendationConfig
|
||||||
|
recSettings *recsettings.Service
|
||||||
rng func() float64
|
rng func() float64
|
||||||
lidarrCfg *lidarrconfig.Service
|
lidarrCfg *lidarrconfig.Service
|
||||||
lidarrRequests *lidarrrequests.Service
|
lidarrRequests *lidarrrequests.Service
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,11 +57,12 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dbtest.ResetDB(t, pool)
|
dbtest.ResetDB(t, pool)
|
||||||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||||
recCfg := config.RecommendationConfig{
|
recCfg := config.RecommendationConfig{
|
||||||
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
|
||||||
SkipPenalty: 1.0, JitterMagnitude: 0.1,
|
|
||||||
ContextWeight: 2.0, SimilarityWeight: 2.0,
|
|
||||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||||
}
|
}
|
||||||
|
recSettings, err := recsettings.New(context.Background(), pool, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recsettings: %v", err)
|
||||||
|
}
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
lidarrCfg := lidarrconfig.New(pool)
|
||||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
||||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
||||||
@@ -70,7 +72,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
||||||
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
||||||
return h, pool
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validDiagnosticKinds is the COARSE category whitelist. Mirrors the
|
||||||
|
// CHECK constraint in migration 0036 — keep both in sync (per the
|
||||||
|
// enum-CHECK-whitelist rule). The finer event discriminator + all
|
||||||
|
// event-specific fields live inside each event's payload, so new event
|
||||||
|
// variants do NOT require a new category here.
|
||||||
|
var validDiagnosticKinds = map[string]struct{}{
|
||||||
|
"connectivity": {},
|
||||||
|
"upnp_sync": {}, // genuinely UPnP/Sonos-specific (drops, resync)
|
||||||
|
"playback": {}, // route + player-state, any output (phone/BT/UPnP)
|
||||||
|
"power": {},
|
||||||
|
"lifecycle": {},
|
||||||
|
"heartbeat": {},
|
||||||
|
"http": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxDiagnosticBatch caps a single ingest call. The client buffers and
|
||||||
|
// flushes in batches of ~100; 500 leaves generous headroom while
|
||||||
|
// bounding a misbehaving client's per-request work.
|
||||||
|
const maxDiagnosticBatch = 500
|
||||||
|
|
||||||
|
// Diagnostics list paging. Larger than the generic parsePaging caps
|
||||||
|
// because the admin timeline is meant to be exported as a slice and
|
||||||
|
// handed off for analysis — a heartbeat-dense window has many rows.
|
||||||
|
const (
|
||||||
|
defaultDiagnosticPageSize = 500
|
||||||
|
maxDiagnosticPageSize = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
// diagnosticEventIn is one event in a POST /api/diagnostics batch.
|
||||||
|
// OccurredAt is the client device clock as epoch milliseconds (may be
|
||||||
|
// skewed — the server also stamps received_at). Payload is opaque JSON
|
||||||
|
// carrying the event's sub-type + fields.
|
||||||
|
type diagnosticEventIn struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportDiagnosticsRequest is the body of POST /api/diagnostics — one
|
||||||
|
// batch from one device.
|
||||||
|
type reportDiagnosticsRequest struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
AppVersion *string `json:"app_version,omitempty"`
|
||||||
|
OsVersion *string `json:"os_version,omitempty"`
|
||||||
|
Events []diagnosticEventIn `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReportDiagnostics implements POST /api/diagnostics. Any signed-in
|
||||||
|
// user can call it, but events are only stored when the account's
|
||||||
|
// debug_mode_enabled flag is on; otherwise the call is a 204 no-op so the
|
||||||
|
// client can safely drop the batch (it gates locally on /api/me, this is
|
||||||
|
// the race-safe backstop for "admin just disabled debug").
|
||||||
|
func (h *handlers) handleReportDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !user.DebugModeEnabled {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req reportDiagnosticsRequest
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ClientID) == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "client_id required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Events) == 0 {
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]int{"accepted": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Events) > maxDiagnosticBatch {
|
||||||
|
writeErr(w, apierror.BadRequest("batch_too_large", "too many events in one batch"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range req.Events {
|
||||||
|
if _, valid := validDiagnosticKinds[req.Events[i].Kind]; !valid {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_kind", "unknown diagnostic kind"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := pgx.BeginFunc(r.Context(), h.pool, func(tx pgx.Tx) error {
|
||||||
|
q := dbq.New(tx)
|
||||||
|
for i := range req.Events {
|
||||||
|
ev := req.Events[i]
|
||||||
|
payload := ev.Payload
|
||||||
|
if len(payload) == 0 {
|
||||||
|
payload = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
if _, err := q.InsertDiagnosticEvent(r.Context(), dbq.InsertDiagnosticEventParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
ClientID: req.ClientID,
|
||||||
|
AppVersion: req.AppVersion,
|
||||||
|
OsVersion: req.OsVersion,
|
||||||
|
Kind: ev.Kind,
|
||||||
|
Payload: payload,
|
||||||
|
OccurredAt: pgtype.Timestamptz{Time: time.UnixMilli(ev.OccurredAt).UTC(), Valid: true},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "diagnostics: insert batch failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]int{"accepted": len(req.Events)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDiagnosticView is one row in the admin timeline response. Payload
|
||||||
|
// is passed through verbatim as raw JSON so the SPA can render / export
|
||||||
|
// the structured event without the server re-shaping it.
|
||||||
|
type adminDiagnosticView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
AppVersion *string `json:"app_version,omitempty"`
|
||||||
|
OsVersion *string `json:"os_version,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
OccurredAt string `json:"occurred_at"`
|
||||||
|
ReceivedAt string `json:"received_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAdminDiagnostics implements GET /api/admin/diagnostics with
|
||||||
|
// optional filters user_id, client_id, kind, from, to (RFC3339 or epoch
|
||||||
|
// millis) plus limit/offset. Newest-first; the SPA reverses for a
|
||||||
|
// chronological timeline + export.
|
||||||
|
func (h *handlers) handleListAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
params, ok := h.buildDiagnosticsFilter(w, q)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListAdminDiagnostics(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: list diagnostics failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]adminDiagnosticView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, adminDiagnosticView{
|
||||||
|
ID: uuidToString(row.ID),
|
||||||
|
UserID: uuidToString(row.UserID),
|
||||||
|
Username: row.Username,
|
||||||
|
ClientID: row.ClientID,
|
||||||
|
AppVersion: row.AppVersion,
|
||||||
|
OsVersion: row.OsVersion,
|
||||||
|
Kind: row.Kind,
|
||||||
|
Payload: json.RawMessage(row.Payload),
|
||||||
|
OccurredAt: formatTimestamp(row.OccurredAt),
|
||||||
|
ReceivedAt: formatTimestamp(row.ReceivedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDiagnosticsFilter parses the query string into a query param
|
||||||
|
// struct, writing a 400 and returning ok=false on a malformed time.
|
||||||
|
func (h *handlers) buildDiagnosticsFilter(
|
||||||
|
w http.ResponseWriter, q map[string][]string,
|
||||||
|
) (dbq.ListAdminDiagnosticsParams, bool) {
|
||||||
|
get := func(k string) string {
|
||||||
|
if v, present := q[k]; present && len(v) > 0 {
|
||||||
|
return v[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
limit, offset, err := parseDiagPaging(get("limit"), get("offset"))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid limit or offset"))
|
||||||
|
return dbq.ListAdminDiagnosticsParams{}, false
|
||||||
|
}
|
||||||
|
from, okFrom := parseDiagTime(get("from"))
|
||||||
|
to, okTo := parseDiagTime(get("to"))
|
||||||
|
if !okFrom || !okTo {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid from/to timestamp"))
|
||||||
|
return dbq.ListAdminDiagnosticsParams{}, false
|
||||||
|
}
|
||||||
|
params := dbq.ListAdminDiagnosticsParams{
|
||||||
|
ClientID: optionalQuery(get("client_id")),
|
||||||
|
Kind: optionalQuery(get("kind")),
|
||||||
|
FromTs: from,
|
||||||
|
ToTs: to,
|
||||||
|
Off: int32(offset),
|
||||||
|
Lim: int32(limit),
|
||||||
|
}
|
||||||
|
if uid, valid := parseUUID(get("user_id")); valid {
|
||||||
|
params.UserID = uid
|
||||||
|
}
|
||||||
|
return params, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDiagnosticDeviceView is one device in the "who is reporting"
|
||||||
|
// overview / device filter dropdown.
|
||||||
|
type adminDiagnosticDeviceView struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
OsVersion string `json:"os_version"`
|
||||||
|
LastSeen string `json:"last_seen"`
|
||||||
|
EventCount int64 `json:"event_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAdminDiagnosticDevices implements GET
|
||||||
|
// /api/admin/diagnostics/devices?user_id=. Lists distinct reporting
|
||||||
|
// devices so the admin UI can populate its device filter.
|
||||||
|
func (h *handlers) handleListAdminDiagnosticDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var userID pgtype.UUID
|
||||||
|
if uid, valid := parseUUID(r.URL.Query().Get("user_id")); valid {
|
||||||
|
userID = uid
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListDiagnosticDevices(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: list diagnostic devices failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]adminDiagnosticDeviceView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, adminDiagnosticDeviceView{
|
||||||
|
ClientID: row.ClientID,
|
||||||
|
UserID: uuidToString(row.UserID),
|
||||||
|
Username: row.Username,
|
||||||
|
AppVersion: row.AppVersion,
|
||||||
|
OsVersion: row.OsVersion,
|
||||||
|
LastSeen: formatTimestamp(row.LastSeen),
|
||||||
|
EventCount: row.EventCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDebugModeReq is the body of PUT /api/admin/users/{id}/debug-mode.
|
||||||
|
type adminDebugModeReq struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminDebugModeToggle implements PUT
|
||||||
|
// /api/admin/users/{id}/debug-mode — flip an account's diagnostics
|
||||||
|
// opt-in remotely while a bug is live. Echoes the updated user.
|
||||||
|
func (h *handlers) handleAdminDebugModeToggle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetID, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req adminDebugModeReq
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := dbq.New(h.pool).SetDebugMode(r.Context(), dbq.SetDebugModeParams{
|
||||||
|
ID: targetID,
|
||||||
|
DebugModeEnabled: req.Enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: set debug mode failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Info("admin: debug mode toggled",
|
||||||
|
"target_user", uuidToString(targetID), "enabled", req.Enabled)
|
||||||
|
writeJSON(w, http.StatusOK, adminUserView{
|
||||||
|
ID: uuidToString(updated.ID),
|
||||||
|
Username: updated.Username,
|
||||||
|
DisplayName: updated.DisplayName,
|
||||||
|
IsAdmin: updated.IsAdmin,
|
||||||
|
AutoApproveRequests: updated.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: updated.DebugModeEnabled,
|
||||||
|
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDiagPaging reads limit/offset with diagnostics-specific caps.
|
||||||
|
// Blank → defaults; out-of-range clamps; non-numeric → error.
|
||||||
|
func parseDiagPaging(rawLimit, rawOffset string) (limit, offset int, err error) {
|
||||||
|
limit = defaultDiagnosticPageSize
|
||||||
|
if s := strings.TrimSpace(rawLimit); s != "" {
|
||||||
|
n, perr := strconv.Atoi(s)
|
||||||
|
if perr != nil {
|
||||||
|
return 0, 0, perr
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
if n > maxDiagnosticPageSize {
|
||||||
|
n = maxDiagnosticPageSize
|
||||||
|
}
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(rawOffset); s != "" {
|
||||||
|
n, perr := strconv.Atoi(s)
|
||||||
|
if perr != nil {
|
||||||
|
return 0, 0, perr
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
offset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return limit, offset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalQuery returns nil for an absent/blank query value so the sqlc
|
||||||
|
// NULL-or-equals filter treats it as "no filter."
|
||||||
|
func optionalQuery(raw string) *string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDiagTime accepts RFC3339 or epoch-millis. Empty = absent (NULL
|
||||||
|
// filter, ok=true). Returns ok=false only on a non-empty unparseable value.
|
||||||
|
func parseDiagTime(raw string) (pgtype.Timestamptz, bool) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return pgtype.Timestamptz{}, true
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||||
|
return pgtype.Timestamptz{Time: t.UTC(), Valid: true}, true
|
||||||
|
}
|
||||||
|
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
return pgtype.Timestamptz{Time: time.UnixMilli(ms).UTC(), Valid: true}, true
|
||||||
|
}
|
||||||
|
return pgtype.Timestamptz{}, false
|
||||||
|
}
|
||||||
+21
-1
@@ -31,6 +31,26 @@ type playStartedResponse struct {
|
|||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eventFutureSkew is the tolerated client-clock drift into the future
|
||||||
|
// before a timestamp is treated as bogus and replaced with now.
|
||||||
|
const eventFutureSkew = 5 * time.Minute
|
||||||
|
|
||||||
|
// clampEventTime bounds a client-supplied event timestamp to sanity:
|
||||||
|
// no earlier than the account's creation (offline replays can
|
||||||
|
// legitimately be days old, but no play can predate the user) and no
|
||||||
|
// later than now + a small skew allowance. Unbounded client clocks
|
||||||
|
// previously let a skewed device write arbitrarily old plays, which
|
||||||
|
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
|
||||||
|
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
|
||||||
|
if at.Before(userCreatedAt) {
|
||||||
|
return userCreatedAt
|
||||||
|
}
|
||||||
|
if at.After(now.Add(eventFutureSkew)) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
type okResponse struct {
|
type okResponse struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
}
|
}
|
||||||
@@ -52,7 +72,7 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
|
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
at = parsed
|
at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC())
|
||||||
}
|
}
|
||||||
clientID := ""
|
clientID := ""
|
||||||
if req.ClientID != nil {
|
if req.ClientID != nil {
|
||||||
|
|||||||
@@ -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
|
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
|
||||||
// the request's original requester so their /requests page reflects the
|
// the request's original requester so their /requests page reflects the
|
||||||
// new state without manual refresh. Admin actors (approve / reject) still
|
// new state without manual refresh. Admin actors (approve / reject) still
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
30*time.Minute, 0.5, 30000)
|
||||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ type meProfileResp struct {
|
|||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
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) {
|
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 {
|
func profileViewFromUser(u dbq.User) meProfileResp {
|
||||||
return meProfileResp{
|
return meProfileResp{
|
||||||
ID: uuidToString(u.ID),
|
ID: uuidToString(u.ID),
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName,
|
||||||
Email: u.Email,
|
Email: u.Email,
|
||||||
IsAdmin: u.IsAdmin,
|
IsAdmin: u.IsAdmin,
|
||||||
|
DebugModeEnabled: u.DebugModeEnabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
recMetricsDefaultDays = 30
|
||||||
|
recMetricsMaxDays = 365
|
||||||
|
|
||||||
|
// recMetricsLowVolume marks a family as low-confidence rather than
|
||||||
|
// hiding it: with fewer plays than this a skip rate is anecdote, not
|
||||||
|
// signal, but silently dropping the row would misread as "surface
|
||||||
|
// unused". The web renders low-confidence rows dimmed.
|
||||||
|
recMetricsLowVolume = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// Surface intents (milestone #127): each family is judged against its
|
||||||
|
// job, not one global bar — discovery mixes are EXPECTED to run higher
|
||||||
|
// skip rates than the go-to surfaces.
|
||||||
|
const (
|
||||||
|
intentGoTo = "go_to"
|
||||||
|
intentDiscovery = "discovery"
|
||||||
|
intentDirect = "direct"
|
||||||
|
)
|
||||||
|
|
||||||
|
// surfaceMetric is one bucketed surface family's outcomes.
|
||||||
|
type surfaceMetric struct {
|
||||||
|
Key string `json:"key"` // stable family key ("for_you", "radio", …)
|
||||||
|
Label string `json:"label"` // display label
|
||||||
|
Plays int64 `json:"plays"` // plays launched from this family
|
||||||
|
Skips int64 `json:"skips"` // of those, marked skipped
|
||||||
|
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||||
|
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||||
|
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
||||||
|
// Breakdown splits the family into the pick-kind populations its
|
||||||
|
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
|
||||||
|
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
||||||
|
// that predate attribution. Present only when the family has at
|
||||||
|
// least one attributed play; the parent row remains the sum of its
|
||||||
|
// breakdown.
|
||||||
|
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// surfaceGroup is one intent band of surface families.
|
||||||
|
type surfaceGroup struct {
|
||||||
|
Intent string `json:"intent"` // go_to | discovery | direct
|
||||||
|
Label string `json:"label"`
|
||||||
|
Surfaces []surfaceMetric `json:"surfaces"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recommendationMetricsResp struct {
|
||||||
|
WindowDays int `json:"window_days"`
|
||||||
|
// Baseline is the control group: plays the user picked manually
|
||||||
|
// (source IS NULL). Surfaces are judged as deltas against it; nil
|
||||||
|
// when the window holds no manual plays.
|
||||||
|
Baseline *surfaceMetric `json:"baseline"`
|
||||||
|
Groups []surfaceGroup `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// recFamily is the bucketing target for a raw play_events.source value.
|
||||||
|
type recFamily struct {
|
||||||
|
key string
|
||||||
|
label string
|
||||||
|
intent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketRecSource maps a raw client-stamped source string to its stable
|
||||||
|
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
|
||||||
|
// their family so the table stays readable at any library size.
|
||||||
|
func bucketRecSource(src string) recFamily {
|
||||||
|
switch {
|
||||||
|
case src == "for_you":
|
||||||
|
return recFamily{"for_you", "For You", intentGoTo}
|
||||||
|
case src == "songs_like_artist":
|
||||||
|
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
|
||||||
|
case src == "radio" || strings.HasPrefix(src, "radio:"):
|
||||||
|
return recFamily{"radio", "Radio", intentGoTo}
|
||||||
|
case src == "discover":
|
||||||
|
return recFamily{"discover", "Discover", intentDiscovery}
|
||||||
|
case src == "deep_cuts":
|
||||||
|
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
|
||||||
|
case src == "rediscover":
|
||||||
|
return recFamily{"rediscover", "Rediscover", intentDiscovery}
|
||||||
|
case src == "new_for_you":
|
||||||
|
return recFamily{"new_for_you", "New for you", intentDiscovery}
|
||||||
|
case src == "on_this_day":
|
||||||
|
return recFamily{"on_this_day", "On this day", intentDiscovery}
|
||||||
|
case src == "first_listens":
|
||||||
|
return recFamily{"first_listens", "First listens", intentDiscovery}
|
||||||
|
case strings.HasPrefix(src, "album:"):
|
||||||
|
return recFamily{"direct_album", "Album plays", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "artist:"):
|
||||||
|
return recFamily{"direct_artist", "Artist plays", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "offline:"):
|
||||||
|
return recFamily{"offline", "Offline pools", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "home:"):
|
||||||
|
return recFamily{"home", "Home sections", intentDirect}
|
||||||
|
case src == "history":
|
||||||
|
return recFamily{"history", "History", intentDirect}
|
||||||
|
default:
|
||||||
|
return recFamily{"other", "Other", intentDirect}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// familyAccum merges raw source rows into one family, carrying the
|
||||||
|
// completion sample count so the merged average stays play-weighted.
|
||||||
|
type familyAccum struct {
|
||||||
|
fam recFamily
|
||||||
|
plays int64
|
||||||
|
skips int64
|
||||||
|
completionN int64
|
||||||
|
// completionSum is avg*count re-expanded, so merging N raw rows
|
||||||
|
// reduces to a single weighted division at the end.
|
||||||
|
completionSum float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
||||||
|
a.plays += row.Plays
|
||||||
|
a.skips += row.Skips
|
||||||
|
a.completionN += row.CompletionN
|
||||||
|
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *familyAccum) metric() surfaceMetric {
|
||||||
|
m := surfaceMetric{
|
||||||
|
Key: a.fam.key,
|
||||||
|
Label: a.fam.label,
|
||||||
|
Plays: a.plays,
|
||||||
|
Skips: a.skips,
|
||||||
|
LowConfidence: a.plays < recMetricsLowVolume,
|
||||||
|
}
|
||||||
|
if a.plays > 0 {
|
||||||
|
m.SkipRate = float64(a.skips) / float64(a.plays)
|
||||||
|
}
|
||||||
|
if a.completionN > 0 {
|
||||||
|
m.AvgCompletion = a.completionSum / float64(a.completionN)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||||
|
// Bucketed per-surface-family outcomes for the caller over the last `days`
|
||||||
|
// (default 30, capped at 365), grouped by surface intent and anchored by the
|
||||||
|
// manual-plays baseline so the numbers are judgeable, not just observable.
|
||||||
|
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days, ok := parseMetricsDays(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := dbq.New(h.pool).RecommendationSourceMetricsForUser(r.Context(),
|
||||||
|
dbq.RecommendationSourceMetricsForUserParams{UserID: caller.ID, Column2: float64(days)})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: recommendation metrics", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindLabels is the display vocabulary for play_events.pick_kind
|
||||||
|
// values (mirrors the CHECK in migration 0039). Every system mix that
|
||||||
|
// stamps provenance gets its breakdown from this one map — adding a
|
||||||
|
// stamping mix needs no metrics change.
|
||||||
|
var pickKindLabels = map[string]string{
|
||||||
|
"taste": "Taste picks",
|
||||||
|
"fresh": "Fresh picks",
|
||||||
|
"dormant": "Dormant artists",
|
||||||
|
"cross_user": "Liked by others",
|
||||||
|
"random": "Random unheard",
|
||||||
|
"tier1": "Tier 1 (exact)",
|
||||||
|
"tier2": "Tier 2 (relaxed)",
|
||||||
|
"tier3": "Tier 3 (stretched)",
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
|
||||||
|
// pick_kind — plays recorded before the mix stamped provenance, or
|
||||||
|
// whose track had rotated out of the snapshot at ingestion) renders
|
||||||
|
// last, kept visible so the parent row's sums stay transparent instead
|
||||||
|
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
|
||||||
|
// vocabulary, so iterating the list is exhaustive.
|
||||||
|
var pickKindOrder = []string{
|
||||||
|
"taste", "fresh", "dormant", "cross_user", "random",
|
||||||
|
"tier1", "tier2", "tier3", "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindFamily derives the sub-family for one (family, pick_kind)
|
||||||
|
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
|
||||||
|
func pickKindFamily(parent recFamily, kind string) recFamily {
|
||||||
|
if kind == "" {
|
||||||
|
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
|
||||||
|
}
|
||||||
|
label, ok := pickKindLabels[kind]
|
||||||
|
if !ok {
|
||||||
|
label = kind
|
||||||
|
}
|
||||||
|
return recFamily{parent.key + "_" + kind, label, parent.intent}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindBreakdown folds a family's per-pick-kind accums into its
|
||||||
|
// Breakdown rows. Attached only when at least one attributed play
|
||||||
|
// exists — an all-unattributed breakdown would just repeat the parent
|
||||||
|
// row, and families that never stamp (radio, direct plays) stay flat.
|
||||||
|
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
||||||
|
attributed := int64(0)
|
||||||
|
for kind, acc := range picks {
|
||||||
|
if kind != "" {
|
||||||
|
attributed += acc.plays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attributed == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]surfaceMetric, 0, len(picks))
|
||||||
|
for _, kind := range pickKindOrder {
|
||||||
|
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
||||||
|
out = append(out, acc.metric())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketMetricsResponse folds the raw per-source rows into the grouped,
|
||||||
|
// baseline-anchored response shape. Split from the handler for pure-unit
|
||||||
|
// testability.
|
||||||
|
func bucketMetricsResponse(
|
||||||
|
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
|
||||||
|
) recommendationMetricsResp {
|
||||||
|
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
|
||||||
|
families := map[string]*familyAccum{}
|
||||||
|
picks := map[string]map[string]*familyAccum{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Source == nil || *row.Source == "" {
|
||||||
|
baseline.add(row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fam := bucketRecSource(*row.Source)
|
||||||
|
acc, exists := families[fam.key]
|
||||||
|
if !exists {
|
||||||
|
acc = &familyAccum{fam: fam}
|
||||||
|
families[fam.key] = acc
|
||||||
|
}
|
||||||
|
acc.add(row)
|
||||||
|
// Accumulate the pick-kind population unconditionally; families
|
||||||
|
// that never stamp end up all-unattributed and get no breakdown.
|
||||||
|
kind := ""
|
||||||
|
if row.PickKind != nil {
|
||||||
|
kind = *row.PickKind
|
||||||
|
}
|
||||||
|
byKind, ok := picks[fam.key]
|
||||||
|
if !ok {
|
||||||
|
byKind = map[string]*familyAccum{}
|
||||||
|
picks[fam.key] = byKind
|
||||||
|
}
|
||||||
|
pick, ok := byKind[kind]
|
||||||
|
if !ok {
|
||||||
|
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
|
||||||
|
byKind[kind] = pick
|
||||||
|
}
|
||||||
|
pick.add(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
|
||||||
|
if baseline.plays > 0 {
|
||||||
|
m := baseline.metric()
|
||||||
|
resp.Baseline = &m
|
||||||
|
}
|
||||||
|
for _, g := range []struct{ intent, label string }{
|
||||||
|
{intentGoTo, "Go-to surfaces"},
|
||||||
|
{intentDiscovery, "Discovery mixes"},
|
||||||
|
{intentDirect, "Direct plays"},
|
||||||
|
} {
|
||||||
|
group := surfaceGroup{Intent: g.intent, Label: g.label}
|
||||||
|
for _, acc := range families {
|
||||||
|
if acc.fam.intent == g.intent {
|
||||||
|
m := acc.metric()
|
||||||
|
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
|
||||||
|
group.Surfaces = append(group.Surfaces, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(group.Surfaces) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sort.Slice(group.Surfaces, func(i, j int) bool {
|
||||||
|
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
|
||||||
|
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
|
||||||
|
}
|
||||||
|
return group.Surfaces[i].Key < group.Surfaces[j].Key
|
||||||
|
})
|
||||||
|
resp.Groups = append(resp.Groups, group)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
||||||
|
// Writes a 400 and returns ok=false on a malformed value.
|
||||||
|
func parseMetricsDays(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||||
|
v := r.URL.Query().Get("days")
|
||||||
|
if v == "" {
|
||||||
|
return recMetricsDefaultDays, true
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid days"))
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if n > recMetricsMaxDays {
|
||||||
|
n = recMetricsMaxDays
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newMetricsRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
||||||
|
// skip flag. A nil source inserts NULL (manual library play → baseline).
|
||||||
|
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
|
||||||
|
// except attributed for_you plays.
|
||||||
|
func seedSourcedPlay(
|
||||||
|
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
||||||
|
source, pickKind *string, completion float64, skipped bool,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := h.pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO play_events
|
||||||
|
(user_id, track_id, session_id, started_at, source, pick_kind, completion_ratio, was_skipped)
|
||||||
|
VALUES ($1, $2, $3, now(), $4, $5, $6, $7)`,
|
||||||
|
userID, trackID, sessionID, source, pickKind, completion, skipped); err != nil {
|
||||||
|
t.Fatalf("seed sourced play: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationMetrics_NoSession401(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newMetricsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSurface returns the named family from any group, or nil.
|
||||||
|
func findSurface(resp recommendationMetricsResp, key string) *surfaceMetric {
|
||||||
|
for _, g := range resp.Groups {
|
||||||
|
for i := range g.Surfaces {
|
||||||
|
if g.Surfaces[i].Key == key {
|
||||||
|
return &g.Surfaces[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("for_you"), Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 2.0 / 3},
|
||||||
|
// Two radio sessions collapse into one "radio" family; weighted
|
||||||
|
// completion = (0.2*1 + 0.8*1) / 2 = 0.5.
|
||||||
|
{Source: src("radio:aaaa"), Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2},
|
||||||
|
{Source: src("radio:bbbb"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
|
||||||
|
{Source: src("album:cccc"), Plays: 1, Skips: 0, CompletionN: 0, AvgCompletion: 0},
|
||||||
|
{Source: src("discover"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
|
||||||
|
// NULL source = manual plays → baseline, not a group row.
|
||||||
|
{Source: nil, Plays: 25, Skips: 5, CompletionN: 20, AvgCompletion: 0.9},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
if resp.Baseline == nil {
|
||||||
|
t.Fatal("baseline missing")
|
||||||
|
}
|
||||||
|
if resp.Baseline.Plays != 25 || resp.Baseline.SkipRate != 0.2 {
|
||||||
|
t.Errorf("baseline = %+v, want plays=25 skip_rate=0.2", resp.Baseline)
|
||||||
|
}
|
||||||
|
if resp.Baseline.LowConfidence {
|
||||||
|
t.Error("baseline with 25 plays should not be low-confidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
radio := findSurface(resp, "radio")
|
||||||
|
if radio == nil {
|
||||||
|
t.Fatal("radio family missing")
|
||||||
|
}
|
||||||
|
if radio.Plays != 2 || radio.Skips != 1 {
|
||||||
|
t.Errorf("radio plays/skips = %d/%d, want 2/1", radio.Plays, radio.Skips)
|
||||||
|
}
|
||||||
|
if radio.AvgCompletion < 0.49 || radio.AvgCompletion > 0.51 {
|
||||||
|
t.Errorf("radio avg_completion = %.3f, want 0.5 (play-weighted merge)", radio.AvgCompletion)
|
||||||
|
}
|
||||||
|
if !radio.LowConfidence {
|
||||||
|
t.Error("radio with 2 plays should be low-confidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := findSurface(resp, "direct_album"); s == nil || s.Plays != 1 {
|
||||||
|
t.Errorf("direct_album = %+v, want plays=1", s)
|
||||||
|
}
|
||||||
|
if s := findSurface(resp, ""); s != nil {
|
||||||
|
t.Error("NULL source must not appear as a surface family")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No attributed (taste/fresh) plays in this fixture → no breakdown;
|
||||||
|
// an all-unattributed breakdown would just repeat the parent row.
|
||||||
|
if fy := findSurface(resp, "for_you"); fy == nil || fy.Breakdown != nil {
|
||||||
|
t.Errorf("for_you breakdown = %+v, want nil without attributed plays", fy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group ordering is intent-banded: go_to before discovery before direct.
|
||||||
|
wantOrder := []string{intentGoTo, intentDiscovery, intentDirect}
|
||||||
|
if len(resp.Groups) != len(wantOrder) {
|
||||||
|
t.Fatalf("groups = %d, want %d", len(resp.Groups), len(wantOrder))
|
||||||
|
}
|
||||||
|
for i, g := range resp.Groups {
|
||||||
|
if g.Intent != wantOrder[i] {
|
||||||
|
t.Errorf("group[%d].intent = %s, want %s", i, g.Intent, wantOrder[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
kind := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("for_you"), PickKind: kind("taste"), Plays: 30, Skips: 3,
|
||||||
|
CompletionN: 30, AvgCompletion: 0.9},
|
||||||
|
{Source: src("for_you"), PickKind: kind("fresh"), Plays: 10, Skips: 4,
|
||||||
|
CompletionN: 10, AvgCompletion: 0.5},
|
||||||
|
// NULL pick_kind = plays that predate attribution.
|
||||||
|
{Source: src("for_you"), Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.7},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
fy := findSurface(resp, "for_you")
|
||||||
|
if fy == nil {
|
||||||
|
t.Fatal("for_you family missing")
|
||||||
|
}
|
||||||
|
// The parent row stays the sum of its breakdown.
|
||||||
|
if fy.Plays != 45 || fy.Skips != 8 {
|
||||||
|
t.Errorf("for_you plays/skips = %d/%d, want 45/8", fy.Plays, fy.Skips)
|
||||||
|
}
|
||||||
|
if len(fy.Breakdown) != 3 {
|
||||||
|
t.Fatalf("breakdown rows = %d, want 3 (taste, fresh, earlier)", len(fy.Breakdown))
|
||||||
|
}
|
||||||
|
wantKeys := []string{"for_you_taste", "for_you_fresh", "for_you_unattributed"}
|
||||||
|
for i, k := range wantKeys {
|
||||||
|
if fy.Breakdown[i].Key != k {
|
||||||
|
t.Errorf("breakdown[%d].key = %s, want %s", i, fy.Breakdown[i].Key, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taste, fresh := fy.Breakdown[0], fy.Breakdown[1]
|
||||||
|
if taste.Plays != 30 || taste.SkipRate != 0.1 || taste.LowConfidence {
|
||||||
|
t.Errorf("taste = %+v, want plays=30 skip_rate=0.1 confident", taste)
|
||||||
|
}
|
||||||
|
if fresh.Plays != 10 || fresh.SkipRate != 0.4 || !fresh.LowConfidence {
|
||||||
|
t.Errorf("fresh = %+v, want plays=10 skip_rate=0.4 low-confidence", fresh)
|
||||||
|
}
|
||||||
|
// Breakdown rows never appear as their own surface families.
|
||||||
|
if s := findSurface(resp, "for_you_taste"); s != nil {
|
||||||
|
t.Error("for_you_taste must not be a top-level surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
|
||||||
|
// Provenance is standard (#1270): Discover's bucket stamps surface as
|
||||||
|
// a breakdown exactly like For You's taste/fresh — this is what makes
|
||||||
|
// the 40/30/30 allocation judgeable instead of a guess.
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
kind := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("discover"), PickKind: kind("dormant"), Plays: 8, Skips: 2,
|
||||||
|
CompletionN: 8, AvgCompletion: 0.8},
|
||||||
|
{Source: src("discover"), PickKind: kind("cross_user"), Plays: 6, Skips: 3,
|
||||||
|
CompletionN: 6, AvgCompletion: 0.6},
|
||||||
|
{Source: src("discover"), PickKind: kind("random"), Plays: 4, Skips: 3,
|
||||||
|
CompletionN: 4, AvgCompletion: 0.4},
|
||||||
|
// NULL pick_kind = plays that predate bucket stamping.
|
||||||
|
{Source: src("discover"), Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
d := findSurface(resp, "discover")
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("discover family missing")
|
||||||
|
}
|
||||||
|
if d.Plays != 20 || d.Skips != 8 {
|
||||||
|
t.Errorf("discover plays/skips = %d/%d, want 20/8", d.Plays, d.Skips)
|
||||||
|
}
|
||||||
|
wantKeys := []string{
|
||||||
|
"discover_dormant", "discover_cross_user", "discover_random",
|
||||||
|
"discover_unattributed",
|
||||||
|
}
|
||||||
|
if len(d.Breakdown) != len(wantKeys) {
|
||||||
|
t.Fatalf("breakdown rows = %d, want %d", len(d.Breakdown), len(wantKeys))
|
||||||
|
}
|
||||||
|
for i, k := range wantKeys {
|
||||||
|
if d.Breakdown[i].Key != k {
|
||||||
|
t.Errorf("breakdown[%d].key = %s, want %s", i, d.Breakdown[i].Key, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b := d.Breakdown[0]; b.Label != "Dormant artists" || b.Plays != 8 {
|
||||||
|
t.Errorf("dormant row = %+v, want label=Dormant artists plays=8", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
user := seedUser(t, pool, "metrics", "pw", false)
|
||||||
|
artist := seedArtist(t, pool, "MetricArtist")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "MetricAlbum", 2020)
|
||||||
|
tk := seedTrack(t, pool, album.ID, artist.ID, "MetricTrack", 1, 200000)
|
||||||
|
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||||
|
|
||||||
|
forYou := "for_you"
|
||||||
|
radioA := "radio:11111111-1111-1111-1111-111111111111"
|
||||||
|
taste, fresh := "taste", "fresh"
|
||||||
|
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
||||||
|
// Two attributed as taste picks, the skipped one as a fresh pick (#1249).
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false)
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false)
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true)
|
||||||
|
// A radio session play collapses into the "radio" family.
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, nil, 0.8, false)
|
||||||
|
// Manual play (NULL source) — the baseline row.
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, nil, 1.0, false)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||||
|
req = withUser(req, user)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newMetricsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp recommendationMetricsResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.WindowDays != recMetricsDefaultDays {
|
||||||
|
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
||||||
|
}
|
||||||
|
if resp.Baseline == nil || resp.Baseline.Plays != 1 {
|
||||||
|
t.Fatalf("baseline = %+v, want plays=1", resp.Baseline)
|
||||||
|
}
|
||||||
|
fy := findSurface(resp, "for_you")
|
||||||
|
if fy == nil {
|
||||||
|
t.Fatal("for_you metrics missing")
|
||||||
|
}
|
||||||
|
if fy.Plays != 3 || fy.Skips != 1 {
|
||||||
|
t.Errorf("for_you plays/skips = %d/%d, want 3/1", fy.Plays, fy.Skips)
|
||||||
|
}
|
||||||
|
if fy.SkipRate < 0.32 || fy.SkipRate > 0.34 {
|
||||||
|
t.Errorf("for_you skip_rate = %.3f, want ~0.333", fy.SkipRate)
|
||||||
|
}
|
||||||
|
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
||||||
|
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
||||||
|
}
|
||||||
|
// Pick-kind attribution surfaces as the For You breakdown (#1249).
|
||||||
|
if len(fy.Breakdown) != 2 {
|
||||||
|
t.Fatalf("for_you breakdown rows = %d, want 2 (taste, fresh)", len(fy.Breakdown))
|
||||||
|
}
|
||||||
|
if b := fy.Breakdown[0]; b.Key != "for_you_taste" || b.Plays != 2 || b.Skips != 0 {
|
||||||
|
t.Errorf("breakdown[0] = %+v, want for_you_taste plays=2 skips=0", b)
|
||||||
|
}
|
||||||
|
if b := fy.Breakdown[1]; b.Key != "for_you_fresh" || b.Plays != 1 || b.Skips != 1 {
|
||||||
|
t.Errorf("breakdown[1] = %+v, want for_you_fresh plays=1 skips=1", b)
|
||||||
|
}
|
||||||
|
if radio := findSurface(resp, "radio"); radio == nil || radio.Plays != 1 {
|
||||||
|
t.Errorf("radio family = %+v, want plays=1 (collapsed from radio:<uuid>)", radio)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Re
|
|||||||
writeErr(w, apierror.InternalMsg("build failed", err))
|
writeErr(w, apierror.InternalMsg("build failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// #968: announce the rebuild so the user's other clients refresh.
|
||||||
|
h.publishSystemRebuilt(user.ID)
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
v := kind
|
v := kind
|
||||||
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||||
|
|||||||
+4
-10
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RadioResponse is the body of GET /api/radio.
|
// RadioResponse is the body of GET /api/radio.
|
||||||
@@ -100,16 +101,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
weights := recommendation.ScoringWeights{
|
// Scoring weights come from the DB-backed tuning lab (#1250) —
|
||||||
BaseWeight: h.recCfg.BaseWeight,
|
// read per request so an admin change takes effect live.
|
||||||
LikeBoost: h.recCfg.LikeBoost,
|
weights := h.recSettings.Weights(recsettings.ScopeRadio)
|
||||||
RecencyWeight: h.recCfg.RecencyWeight,
|
|
||||||
SkipPenalty: h.recCfg.SkipPenalty,
|
|
||||||
JitterMagnitude: h.recCfg.JitterMagnitude,
|
|
||||||
ContextWeight: h.recCfg.ContextWeight,
|
|
||||||
SimilarityWeight: h.recCfg.SimilarityWeight,
|
|
||||||
TasteWeight: h.recCfg.TasteWeight,
|
|
||||||
}
|
|
||||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||||
|
|
||||||
out := make([]TrackRef, 0, len(picks)+1)
|
out := make([]TrackRef, 0, len(picks)+1)
|
||||||
|
|||||||
+12
-23
@@ -88,20 +88,14 @@ type EventsConfig struct {
|
|||||||
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
|
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6).
|
// RecommendationConfig holds the radio path's operational knobs. The
|
||||||
// All weights are operator-tunable; defaults match the spec recommendations.
|
// scoring WEIGHTS moved to DB-backed admin settings (#1250,
|
||||||
|
// internal/recsettings) — YAML is bootstrap-only; anything an operator
|
||||||
|
// tunes lives in the UI with live effect.
|
||||||
type RecommendationConfig struct {
|
type RecommendationConfig struct {
|
||||||
BaseWeight float64 `yaml:"base_weight"`
|
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||||
LikeBoost float64 `yaml:"like_boost"`
|
RadioSize int `yaml:"radio_size"`
|
||||||
RecencyWeight float64 `yaml:"recency_weight"`
|
RadioSizeMax int `yaml:"radio_size_max"`
|
||||||
SkipPenalty float64 `yaml:"skip_penalty"`
|
|
||||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
|
||||||
ContextWeight float64 `yaml:"context_weight"`
|
|
||||||
SimilarityWeight float64 `yaml:"similarity_weight"`
|
|
||||||
TasteWeight float64 `yaml:"taste_weight"`
|
|
||||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
|
||||||
RadioSize int `yaml:"radio_size"`
|
|
||||||
RadioSizeMax int `yaml:"radio_size_max"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
@@ -120,21 +114,16 @@ func Default() Config {
|
|||||||
SkipMaxDurationPlayedMs: 30000,
|
SkipMaxDurationPlayedMs: 30000,
|
||||||
},
|
},
|
||||||
Recommendation: RecommendationConfig{
|
Recommendation: RecommendationConfig{
|
||||||
BaseWeight: 1.0,
|
|
||||||
LikeBoost: 2.0,
|
|
||||||
RecencyWeight: 1.0,
|
|
||||||
SkipPenalty: 1.0,
|
|
||||||
JitterMagnitude: 0.1,
|
|
||||||
ContextWeight: 2.0,
|
|
||||||
SimilarityWeight: 2.0,
|
|
||||||
// Radio is seed-directed (the user picked a direction), so taste
|
|
||||||
// is a lighter nudge here than in the daily mixes (1.5).
|
|
||||||
TasteWeight: 1.0,
|
|
||||||
RecentlyPlayedHours: 1,
|
RecentlyPlayedHours: 1,
|
||||||
RadioSize: 50,
|
RadioSize: 50,
|
||||||
RadioSizeMax: 200,
|
RadioSizeMax: 200,
|
||||||
},
|
},
|
||||||
Library: LibraryConfig{
|
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,
|
CoverArtFromMBCAA: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ func TestEnvOverrides(t *testing.T) {
|
|||||||
func TestLibraryEnvOverrides(t *testing.T) {
|
func TestLibraryEnvOverrides(t *testing.T) {
|
||||||
stubStreamSecret(t)
|
stubStreamSecret(t)
|
||||||
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
|
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("")
|
cfg, err := Load("")
|
||||||
if err != nil {
|
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)
|
t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !cfg.Library.ScanOnStartup {
|
if cfg.Library.ScanOnStartup {
|
||||||
t.Error("scan_on_startup = false, want true")
|
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
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
||||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events
|
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events
|
||||||
WHERE user_id = $1 AND ended_at IS NULL
|
WHERE user_id = $1 AND ended_at IS NULL
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -79,12 +79,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
||||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events WHERE id = $1
|
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
||||||
@@ -104,15 +105,48 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSystemPickKindForTrack = `-- name: GetSystemPickKindForTrack :one
|
||||||
|
SELECT pt.pick_kind
|
||||||
|
FROM playlist_tracks pt
|
||||||
|
JOIN playlists p ON p.id = pt.playlist_id
|
||||||
|
WHERE p.user_id = $1
|
||||||
|
AND p.system_variant = $2
|
||||||
|
AND pt.track_id = $3
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSystemPickKindForTrackParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
SystemVariant *string
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks a track up in the user's CURRENT snapshot of the given system
|
||||||
|
// variant and returns its pick_kind. Used at play-ingestion time to
|
||||||
|
// freeze provenance onto the play_event — snapshots rebuild daily, so
|
||||||
|
// attribution can't be reconstructed at read time (#1249/#1270). No
|
||||||
|
// row = track not in today's snapshot (caller stores NULL); a row with
|
||||||
|
// NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
|
||||||
|
// is non-singleton (up to 3 mixes/user); a track in two of them takes
|
||||||
|
// whichever LIMIT 1 hits — acceptable, tier stamps there describe the
|
||||||
|
// same eligibility ladder.
|
||||||
|
func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPickKindForTrackParams) (*string, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSystemPickKindForTrack, arg.UserID, arg.SystemVariant, arg.TrackID)
|
||||||
|
var pick_kind *string
|
||||||
|
err := row.Scan(&pick_kind)
|
||||||
|
return pick_kind, err
|
||||||
|
}
|
||||||
|
|
||||||
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
||||||
INSERT INTO play_events (
|
INSERT INTO play_events (
|
||||||
user_id, track_id, session_id, started_at, client_id, source
|
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
|
||||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
|
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertPlayEventParams struct {
|
type InsertPlayEventParams struct {
|
||||||
@@ -122,8 +156,12 @@ type InsertPlayEventParams struct {
|
|||||||
StartedAt pgtype.Timestamptz
|
StartedAt pgtype.Timestamptz
|
||||||
ClientID *string
|
ClientID *string
|
||||||
Source *string
|
Source *string
|
||||||
|
PickKind *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pick_kind is non-NULL only for system-playlist plays whose track was
|
||||||
|
// found (with a stamped kind) in the user's live snapshot for that
|
||||||
|
// variant at ingestion time (#1249, generalized in #1270).
|
||||||
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
|
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
|
||||||
row := q.db.QueryRow(ctx, insertPlayEvent,
|
row := q.db.QueryRow(ctx, insertPlayEvent,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
@@ -132,6 +170,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
|||||||
arg.StartedAt,
|
arg.StartedAt,
|
||||||
arg.ClientID,
|
arg.ClientID,
|
||||||
arg.Source,
|
arg.Source,
|
||||||
|
arg.PickKind,
|
||||||
)
|
)
|
||||||
var i PlayEvent
|
var i PlayEvent
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -148,6 +187,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -291,7 +331,7 @@ SET ended_at = $2,
|
|||||||
completion_ratio = $4,
|
completion_ratio = $4,
|
||||||
was_skipped = $5
|
was_skipped = $5
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
|
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdatePlayEventEndedParams struct {
|
type UpdatePlayEventEndedParams struct {
|
||||||
@@ -328,6 +368,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,18 @@ type CoverArtSourcesMetum struct {
|
|||||||
LastRegisteredProvidersHash string
|
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 {
|
type GeneralLike struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
@@ -371,6 +383,7 @@ type PlayEvent struct {
|
|||||||
SessionVectorAtPlay []byte
|
SessionVectorAtPlay []byte
|
||||||
ScrobbledAt pgtype.Timestamptz
|
ScrobbledAt pgtype.Timestamptz
|
||||||
Source *string
|
Source *string
|
||||||
|
PickKind *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaySession struct {
|
type PlaySession struct {
|
||||||
@@ -421,6 +434,28 @@ type PlaylistTrack struct {
|
|||||||
AlbumTitle string
|
AlbumTitle string
|
||||||
DurationSec int32
|
DurationSec int32
|
||||||
AddedAt pgtype.Timestamptz
|
AddedAt pgtype.Timestamptz
|
||||||
|
PickKind *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationTuningAudit struct {
|
||||||
|
ID int64
|
||||||
|
ChangedAt pgtype.Timestamptz
|
||||||
|
Scope string
|
||||||
|
Action string
|
||||||
|
Changes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationWeightProfile struct {
|
||||||
|
Profile string
|
||||||
|
BaseWeight float64
|
||||||
|
LikeBoost float64
|
||||||
|
RecencyWeight float64
|
||||||
|
SkipPenalty float64
|
||||||
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
|
TasteWeight float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegistrationSetting struct {
|
type RegistrationSetting struct {
|
||||||
@@ -511,6 +546,15 @@ type TasteProfileTag struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TasteTuning struct {
|
||||||
|
Singleton bool
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Title string
|
Title string
|
||||||
@@ -552,6 +596,7 @@ type User struct {
|
|||||||
Email *string
|
Email *string
|
||||||
Timezone string
|
Timezone string
|
||||||
TimezoneUpdatedAt pgtype.Timestamptz
|
TimezoneUpdatedAt pgtype.Timestamptz
|
||||||
|
DebugModeEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserInvite struct {
|
type UserInvite struct {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
|
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
|
||||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
|
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
|
||||||
SELECT
|
SELECT
|
||||||
$1::uuid,
|
$1::uuid,
|
||||||
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
|
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
|
||||||
@@ -20,24 +20,27 @@ SELECT
|
|||||||
t.title,
|
t.title,
|
||||||
artists.name,
|
artists.name,
|
||||||
albums.title,
|
albums.title,
|
||||||
(t.duration_ms / 1000)::integer
|
(t.duration_ms / 1000)::integer,
|
||||||
|
$2::text
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE t.id = $2::uuid
|
WHERE t.id = $3::uuid
|
||||||
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at
|
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind
|
||||||
`
|
`
|
||||||
|
|
||||||
type AppendPlaylistTrackParams struct {
|
type AppendPlaylistTrackParams struct {
|
||||||
PlaylistID pgtype.UUID
|
PlaylistID pgtype.UUID
|
||||||
|
PickKind *string
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inserts at the next available position. Snapshot fields are copied
|
// Inserts at the next available position. Snapshot fields are copied
|
||||||
// from the tracks/albums/artists join at insert time. tracks.duration_ms
|
// from the tracks/albums/artists join at insert time. tracks.duration_ms
|
||||||
// is converted to seconds for the snapshot.
|
// is converted to seconds for the snapshot. pick_kind is NULL for manual
|
||||||
|
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
|
||||||
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
|
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
|
||||||
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID)
|
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
|
||||||
var i PlaylistTrack
|
var i PlaylistTrack
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.PlaylistID,
|
&i.PlaylistID,
|
||||||
@@ -48,6 +51,7 @@ func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTra
|
|||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.DurationSec,
|
&i.DurationSec,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -257,7 +261,7 @@ func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListA
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
|
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
|
||||||
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at,
|
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind,
|
||||||
t.id AS live_track_id,
|
t.id AS live_track_id,
|
||||||
albums.id AS album_id,
|
albums.id AS album_id,
|
||||||
artists.id AS artist_id
|
artists.id AS artist_id
|
||||||
@@ -278,6 +282,7 @@ type ListPlaylistTracksRow struct {
|
|||||||
AlbumTitle string
|
AlbumTitle string
|
||||||
DurationSec int32
|
DurationSec int32
|
||||||
AddedAt pgtype.Timestamptz
|
AddedAt pgtype.Timestamptz
|
||||||
|
PickKind *string
|
||||||
LiveTrackID pgtype.UUID
|
LiveTrackID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
@@ -305,6 +310,7 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID
|
|||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.DurationSec,
|
&i.DurationSec,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
|
&i.PickKind,
|
||||||
&i.LiveTrackID,
|
&i.LiveTrackID,
|
||||||
&i.AlbumID,
|
&i.AlbumID,
|
||||||
&i.ArtistID,
|
&i.ArtistID,
|
||||||
|
|||||||
@@ -737,6 +737,17 @@ likes_overlap AS (
|
|||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $8
|
LIMIT $8
|
||||||
),
|
),
|
||||||
|
taste_overlap AS (
|
||||||
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||||
|
WHERE tpa.user_id = $1
|
||||||
|
AND tpa.weight > 0
|
||||||
|
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
ORDER BY tpa.weight DESC, t.id
|
||||||
|
LIMIT $10
|
||||||
|
),
|
||||||
random_fill AS (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -747,6 +758,7 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM similar_artists
|
UNION SELECT track_id FROM similar_artists
|
||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
|
UNION SELECT track_id FROM taste_overlap
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
LIMIT $9
|
||||||
@@ -763,6 +775,7 @@ FROM (
|
|||||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id
|
||||||
@@ -790,6 +803,7 @@ type LoadRadioCandidatesV2Params struct {
|
|||||||
Limit_3 int32
|
Limit_3 int32
|
||||||
Limit_4 int32
|
Limit_4 int32
|
||||||
Limit_5 int32
|
Limit_5 int32
|
||||||
|
Limit_6 int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadRadioCandidatesV2Row struct {
|
type LoadRadioCandidatesV2Row struct {
|
||||||
@@ -801,11 +815,15 @@ type LoadRadioCandidatesV2Row struct {
|
|||||||
SimilarityScore interface{}
|
SimilarityScore interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// M4c: similarity-driven candidate pool. 5-way UNION:
|
// M4c: similarity-driven candidate pool. 6-way UNION:
|
||||||
//
|
//
|
||||||
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||||
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||||||
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
|
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||||||
|
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||||
|
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||||||
|
// enter the pool even when the similarity/random arms miss them; scored
|
||||||
|
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||||
//
|
//
|
||||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||||
@@ -819,6 +837,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
arg.Limit_3,
|
arg.Limit_3,
|
||||||
arg.Limit_4,
|
arg.Limit_4,
|
||||||
arg.Limit_5,
|
arg.Limit_5,
|
||||||
|
arg.Limit_6,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: recommendation_metrics.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
|
||||||
|
SELECT
|
||||||
|
pe.source,
|
||||||
|
pe.pick_kind,
|
||||||
|
count(*)::bigint AS plays,
|
||||||
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
|
count(pe.completion_ratio)::bigint AS completion_n,
|
||||||
|
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
|
GROUP BY pe.source, pe.pick_kind
|
||||||
|
ORDER BY plays DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecommendationSourceMetricsForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Column2 float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationSourceMetricsForUserRow struct {
|
||||||
|
Source *string
|
||||||
|
PickKind *string
|
||||||
|
Plays int64
|
||||||
|
Skips int64
|
||||||
|
CompletionN int64
|
||||||
|
AvgCompletion float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||||
|
// mean completion ratio over the completion_n plays that recorded one.
|
||||||
|
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
||||||
|
// it is NULL for every other source, so those still group to one row.
|
||||||
|
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationSourceMetricsForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationSourceMetricsForUserRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
|
&i.Plays,
|
||||||
|
&i.Skips,
|
||||||
|
&i.CompletionN,
|
||||||
|
&i.AvgCompletion,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendationWeeklyTrends = `-- name: RecommendationWeeklyTrends :many
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
date_trunc('week', pe.started_at)::date AS week_start,
|
||||||
|
pe.source,
|
||||||
|
count(*)::bigint AS plays,
|
||||||
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
|
count(pe.completion_ratio)::bigint AS completion_n,
|
||||||
|
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
|
||||||
|
count(*) FILTER (WHERE tpa.artist_id IS NOT NULL)::bigint AS taste_hits
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
LEFT JOIN taste_profile_artists tpa
|
||||||
|
ON tpa.user_id = pe.user_id
|
||||||
|
AND tpa.artist_id = t.artist_id
|
||||||
|
AND tpa.weight > 0
|
||||||
|
WHERE pe.started_at > now() - ($1::int * INTERVAL '1 week')
|
||||||
|
GROUP BY 1, 2
|
||||||
|
ORDER BY 1, 2
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecommendationWeeklyTrendsRow struct {
|
||||||
|
WeekStart pgtype.Date
|
||||||
|
Source *string
|
||||||
|
Plays int64
|
||||||
|
Skips int64
|
||||||
|
CompletionN int64
|
||||||
|
AvgCompletion float64
|
||||||
|
TasteHits int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||||
|
// operator can see whether each recommendation surface is landing and tune the
|
||||||
|
// taste weights. Source is stamped on play_events when a play is launched from
|
||||||
|
// a recommendation surface; NULL means the user picked the track manually —
|
||||||
|
// those rows are INCLUDED here as the baseline control group the surfaces are
|
||||||
|
// judged against (milestone #127: delta-vs-baseline is what makes the numbers
|
||||||
|
// actionable). Raw source strings are bucketed into stable surface families in
|
||||||
|
// the Go handler; completion_n is carried so family merges can weight
|
||||||
|
// avg_completion correctly.
|
||||||
|
// Weekly per-source outcome series for the tuning lab's trend view
|
||||||
|
// (#1251). Aggregated across ALL users: the tuning knobs are global,
|
||||||
|
// so judging a knob turn needs global outcomes — rows carry rates
|
||||||
|
// only, no track or user identity. NULL-source (manual) rows are
|
||||||
|
// included as the baseline family.
|
||||||
|
//
|
||||||
|
// taste_hits counts plays whose track's artist has a positive weight
|
||||||
|
// in that user's CURRENT taste profile — the "cheap recompute" option:
|
||||||
|
// retroactive over the whole window, at the cost of drift (the profile
|
||||||
|
// is today's, the play may be weeks old). Good enough to read whether
|
||||||
|
// a surface is feeding taste-fitting tracks.
|
||||||
|
// $1 window in weeks.
|
||||||
|
func (q *Queries) RecommendationWeeklyTrends(ctx context.Context, weeks int32) ([]RecommendationWeeklyTrendsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, recommendationWeeklyTrends, weeks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationWeeklyTrendsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationWeeklyTrendsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.WeekStart,
|
||||||
|
&i.Source,
|
||||||
|
&i.Plays,
|
||||||
|
&i.Skips,
|
||||||
|
&i.CompletionN,
|
||||||
|
&i.AvgCompletion,
|
||||||
|
&i.TasteHits,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: recommendation_tuning.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getTasteTuning = `-- name: GetTasteTuning :one
|
||||||
|
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at FROM taste_tuning WHERE singleton = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getTasteTuning)
|
||||||
|
var i TasteTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.HalfLifeDays,
|
||||||
|
&i.EngagementHardSkip,
|
||||||
|
&i.EngagementNeutral,
|
||||||
|
&i.EngagementFull,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertTuningAudit = `-- name: InsertTuningAudit :exec
|
||||||
|
INSERT INTO recommendation_tuning_audit (scope, action, changes)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertTuningAuditParams struct {
|
||||||
|
Scope string
|
||||||
|
Action string
|
||||||
|
Changes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// changes is a jsonb array of {field, old, new} objects.
|
||||||
|
func (q *Queries) InsertTuningAudit(ctx context.Context, arg InsertTuningAuditParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertTuningAudit, arg.Scope, arg.Action, arg.Changes)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTuningAudit = `-- name: ListTuningAudit :many
|
||||||
|
SELECT id, changed_at, scope, action, changes
|
||||||
|
FROM recommendation_tuning_audit
|
||||||
|
ORDER BY changed_at DESC, id DESC
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Newest first; consumed by the metrics trend view (#1251) to annotate
|
||||||
|
// knob turns on the timeline.
|
||||||
|
func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTuningAudit, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationTuningAudit
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationTuningAudit
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ChangedAt,
|
||||||
|
&i.Scope,
|
||||||
|
&i.Action,
|
||||||
|
&i.Changes,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listWeightProfiles = `-- name: ListWeightProfiles :many
|
||||||
|
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listWeightProfiles)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationWeightProfile
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationWeightProfile
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Profile,
|
||||||
|
&i.BaseWeight,
|
||||||
|
&i.LikeBoost,
|
||||||
|
&i.RecencyWeight,
|
||||||
|
&i.SkipPenalty,
|
||||||
|
&i.JitterMagnitude,
|
||||||
|
&i.ContextWeight,
|
||||||
|
&i.SimilarityWeight,
|
||||||
|
&i.TasteWeight,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTasteTuning = `-- name: UpdateTasteTuning :one
|
||||||
|
UPDATE taste_tuning
|
||||||
|
SET half_life_days = $1,
|
||||||
|
engagement_hard_skip = $2,
|
||||||
|
engagement_neutral = $3,
|
||||||
|
engagement_full = $4,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE singleton = true
|
||||||
|
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTasteTuningParams struct {
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateTasteTuning,
|
||||||
|
arg.HalfLifeDays,
|
||||||
|
arg.EngagementHardSkip,
|
||||||
|
arg.EngagementNeutral,
|
||||||
|
arg.EngagementFull,
|
||||||
|
)
|
||||||
|
var i TasteTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.HalfLifeDays,
|
||||||
|
&i.EngagementHardSkip,
|
||||||
|
&i.EngagementNeutral,
|
||||||
|
&i.EngagementFull,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWeightProfile = `-- name: UpdateWeightProfile :one
|
||||||
|
UPDATE recommendation_weight_profiles
|
||||||
|
SET base_weight = $2,
|
||||||
|
like_boost = $3,
|
||||||
|
recency_weight = $4,
|
||||||
|
skip_penalty = $5,
|
||||||
|
jitter_magnitude = $6,
|
||||||
|
context_weight = $7,
|
||||||
|
similarity_weight = $8,
|
||||||
|
taste_weight = $9,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE profile = $1
|
||||||
|
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateWeightProfileParams struct {
|
||||||
|
Profile string
|
||||||
|
BaseWeight float64
|
||||||
|
LikeBoost float64
|
||||||
|
RecencyWeight float64
|
||||||
|
SkipPenalty float64
|
||||||
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
|
TasteWeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateWeightProfile,
|
||||||
|
arg.Profile,
|
||||||
|
arg.BaseWeight,
|
||||||
|
arg.LikeBoost,
|
||||||
|
arg.RecencyWeight,
|
||||||
|
arg.SkipPenalty,
|
||||||
|
arg.JitterMagnitude,
|
||||||
|
arg.ContextWeight,
|
||||||
|
arg.SimilarityWeight,
|
||||||
|
arg.TasteWeight,
|
||||||
|
)
|
||||||
|
var i RecommendationWeightProfile
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Profile,
|
||||||
|
&i.BaseWeight,
|
||||||
|
&i.LikeBoost,
|
||||||
|
&i.RecencyWeight,
|
||||||
|
&i.SkipPenalty,
|
||||||
|
&i.JitterMagnitude,
|
||||||
|
&i.ContextWeight,
|
||||||
|
&i.SimilarityWeight,
|
||||||
|
&i.TasteWeight,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||||
|
INSERT INTO taste_tuning (
|
||||||
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
|
engagement_neutral, engagement_full
|
||||||
|
) VALUES (true, $1, $2, $3, $4)
|
||||||
|
ON CONFLICT (singleton) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertTasteTuningDefaultsParams struct {
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertTasteTuningDefaults,
|
||||||
|
arg.HalfLifeDays,
|
||||||
|
arg.EngagementHardSkip,
|
||||||
|
arg.EngagementNeutral,
|
||||||
|
arg.EngagementFull,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
|
||||||
|
|
||||||
|
INSERT INTO recommendation_weight_profiles (
|
||||||
|
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||||
|
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (profile) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertWeightProfileDefaultsParams struct {
|
||||||
|
Profile string
|
||||||
|
BaseWeight float64
|
||||||
|
LikeBoost float64
|
||||||
|
RecencyWeight float64
|
||||||
|
SkipPenalty float64
|
||||||
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
|
TasteWeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendation tuning lab queries (#1250). Seeding happens via the
|
||||||
|
// recsettings boot reconcile; shipped defaults live in Go only.
|
||||||
|
// Boot reconcile: insert the shipped defaults for a profile if the row
|
||||||
|
// doesn't exist yet. Never overwrites operator-tuned values.
|
||||||
|
func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertWeightProfileDefaults,
|
||||||
|
arg.Profile,
|
||||||
|
arg.BaseWeight,
|
||||||
|
arg.LikeBoost,
|
||||||
|
arg.RecencyWeight,
|
||||||
|
arg.SkipPenalty,
|
||||||
|
arg.JitterMagnitude,
|
||||||
|
arg.ContextWeight,
|
||||||
|
arg.SimilarityWeight,
|
||||||
|
arg.TasteWeight,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -27,12 +27,20 @@ play_counts AS (
|
|||||||
FROM play_events
|
FROM play_events
|
||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
|
),
|
||||||
|
skip_counts AS (
|
||||||
|
SELECT track_id, COUNT(*) AS c
|
||||||
|
FROM play_events
|
||||||
|
WHERE user_id = $1 AND was_skipped = true
|
||||||
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
||||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||||
|
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||||
WHERE COALESCE(pc.c, 0) <= 2
|
WHERE COALESCE(pc.c, 0) <= 2
|
||||||
|
AND COALESCE(sc.c, 0) < 2
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
@@ -60,6 +68,11 @@ type ListDeepCutsTracksRow struct {
|
|||||||
// gives per-play variety on top.
|
// gives per-play variety on top.
|
||||||
// #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
|
// #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
|
||||||
// has liked OR played heavily (>=5 non-skip plays across the artist).
|
// has liked OR played heavily (>=5 non-skip plays across the artist).
|
||||||
|
// Tracks the user has skipped twice or more don't qualify (#1257):
|
||||||
|
// eligibility used to count only unskipped plays, so the most actively
|
||||||
|
// rejected tracks read as "barely heard" and kept being re-offered.
|
||||||
|
// Threshold 2 so a single accidental skip doesn't banish a track;
|
||||||
|
// passive-signal only (no dislike UI, rule #101).
|
||||||
// $1 user_id, $2 date string.
|
// $1 user_id, $2 date string.
|
||||||
func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) {
|
func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2)
|
rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2)
|
||||||
@@ -82,31 +95,49 @@ func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracks
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listFirstListensTracks = `-- name: ListFirstListensTracks :many
|
const listFirstListensTracks = `-- name: ListFirstListensTracks :many
|
||||||
WITH heard_albums AS (
|
WITH attempted AS (
|
||||||
SELECT DISTINCT t.album_id
|
SELECT DISTINCT pe.track_id
|
||||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
|
||||||
),
|
),
|
||||||
played_artists AS (
|
album_attempts AS (
|
||||||
|
SELECT t.album_id,
|
||||||
|
COUNT(a.track_id) AS attempted_count,
|
||||||
|
COUNT(*) AS track_count
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN attempted a ON a.track_id = t.id
|
||||||
|
GROUP BY t.album_id
|
||||||
|
),
|
||||||
|
attempted_artists AS (
|
||||||
SELECT DISTINCT t.artist_id
|
SELECT DISTINCT t.artist_id
|
||||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||||
WHERE pe.user_id = $1
|
),
|
||||||
|
albums_tiered AS (
|
||||||
|
SELECT aa.album_id,
|
||||||
|
CASE
|
||||||
|
WHEN aa.attempted_count = 0 THEN 1
|
||||||
|
WHEN aa.attempted_count::float / GREATEST(aa.track_count, 1) <= 0.25 THEN 2
|
||||||
|
END AS tier
|
||||||
|
FROM album_attempts aa
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id)
|
JOIN albums_tiered alt ON alt.album_id = al.id
|
||||||
|
WHERE alt.tier IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
|
alt.tier,
|
||||||
(CASE
|
(CASE
|
||||||
WHEN EXISTS (
|
WHEN EXISTS (
|
||||||
SELECT 1 FROM general_likes_artists gla
|
SELECT 1 FROM general_likes_artists gla
|
||||||
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
|
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
|
||||||
) THEN 0
|
) THEN 0
|
||||||
WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1
|
WHEN al.artist_id IN (SELECT artist_id FROM attempted_artists) THEN 1
|
||||||
ELSE 2
|
ELSE 2
|
||||||
END),
|
END),
|
||||||
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||||
@@ -117,12 +148,25 @@ type ListFirstListensTracksRow struct {
|
|||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
|
Tier int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// #423 First Listens: albums the user has never played any track of.
|
// #423 First Listens: songs the user has never heard or even
|
||||||
// Tiered: liked-artist albums first, then played-artist albums, then
|
// attempted. "Attempted" is track-level with a >=30s listen threshold
|
||||||
// the rest — album-coherent within each tier. Not diversity-capped
|
// (#1268, operator decision 2026-07-03) — the old version disqualified
|
||||||
// (whole-album discovery). $1 user_id.
|
// a whole album on ANY play_event, so a 2-second accidental brush
|
||||||
|
// banished it permanently.
|
||||||
|
//
|
||||||
|
// tier 1 the exact desire: albums with ZERO attempted tracks
|
||||||
|
// tier 2 step back: barely-attempted albums (<=25% of tracks
|
||||||
|
// reached 30s) — brushed, never explored. The already-
|
||||||
|
// attempted tracks themselves are excluded; they're not
|
||||||
|
// first listens.
|
||||||
|
//
|
||||||
|
// Within each tier, affinity-ordered as before: liked artist, then
|
||||||
|
// attempted-played artist (>=30s — skip-only contact isn't trust),
|
||||||
|
// then the rest; album-coherent. The tier lands on
|
||||||
|
// playlist_tracks.pick_kind via the producer. $1 user_id.
|
||||||
func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) {
|
func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listFirstListensTracks, userID)
|
rows, err := q.db.Query(ctx, listFirstListensTracks, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -132,7 +176,12 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
|
|||||||
var items []ListFirstListensTracksRow
|
var items []ListFirstListensTracksRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListFirstListensTracksRow
|
var i ListFirstListensTracksRow
|
||||||
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.AlbumID,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.Tier,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -144,39 +193,76 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listNewForYouTracks = `-- name: ListNewForYouTracks :many
|
const listNewForYouTracks = `-- name: ListNewForYouTracks :many
|
||||||
WITH affinity_artists AS (
|
WITH attempted AS (
|
||||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
SELECT DISTINCT pe.track_id
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
|
||||||
|
),
|
||||||
|
consumed_albums AS (
|
||||||
|
SELECT DISTINCT t.album_id
|
||||||
|
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||||
|
),
|
||||||
|
affinity_artists AS (
|
||||||
|
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||||
UNION
|
UNION
|
||||||
SELECT t.artist_id
|
SELECT t.artist_id
|
||||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
|
||||||
GROUP BY t.artist_id
|
GROUP BY t.artist_id
|
||||||
HAVING COUNT(*) >= 3
|
HAVING COUNT(*) >= 3
|
||||||
|
),
|
||||||
|
albums_tiered AS (
|
||||||
|
SELECT al.id AS album_id, al.created_at,
|
||||||
|
CASE
|
||||||
|
WHEN al.created_at >= now() - interval '30 days'
|
||||||
|
AND al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 1
|
||||||
|
WHEN al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS tier
|
||||||
|
FROM albums al
|
||||||
|
WHERE al.created_at >= now() - interval '90 days'
|
||||||
|
AND al.id NOT IN (SELECT album_id FROM consumed_albums)
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
||||||
JOIN affinity_artists aa ON aa.artist_id = al.artist_id
|
WHERE NOT EXISTS (
|
||||||
WHERE al.created_at >= now() - interval '30 days'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
|
||||||
LIMIT 200
|
t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||||
|
LIMIT 300
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListNewForYouTracksRow struct {
|
type ListNewForYouTracksRow struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
|
Tier int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// #421 New for you: tracks from albums added in the last 30 days
|
// #421 New for you, tiered per project rule #131 (#1267). The old
|
||||||
// whose artist the user has liked OR played (>=3 non-skip). Album-
|
// single hard rule (added <30d AND direct affinity) had two failure
|
||||||
// coherent (newest album first, then disc/track) — the producer does
|
// modes with one root: no notion of consumption — the album you
|
||||||
// NOT diversity-cap these; they're meant as whole-album discovery.
|
// devoured in week one crowded the mix for three more weeks, and
|
||||||
// $1 user_id.
|
// after a quiet month nothing qualified at all.
|
||||||
|
//
|
||||||
|
// "Consumed" = any track attempted for >=30s (the First Listens
|
||||||
|
// threshold, #1268): once you've meaningfully engaged, the album is
|
||||||
|
// no longer news. Applies to every tier — played albums leave the
|
||||||
|
// mix the next build.
|
||||||
|
//
|
||||||
|
// tier 1 the exact desire: unconsumed albums added <30d by
|
||||||
|
// direct-affinity artists (liked, or >=3 attempted plays)
|
||||||
|
// tier 2 step back a little: unconsumed affinity albums from the
|
||||||
|
// wider 30-90d window — added while you weren't looking
|
||||||
|
// tier 3 step back more: any unconsumed album added <90d
|
||||||
|
// regardless of affinity, newest first
|
||||||
|
//
|
||||||
|
// Album-coherent within tiers (newest album first, then disc/track);
|
||||||
|
// the producer fills top-down and rotates within tier blocks. The
|
||||||
|
// tier lands on playlist_tracks.pick_kind so metrics can price what
|
||||||
|
// each step-back trades away. $1 user_id.
|
||||||
func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) {
|
func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listNewForYouTracks, userID)
|
rows, err := q.db.Query(ctx, listNewForYouTracks, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,7 +272,12 @@ func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) (
|
|||||||
var items []ListNewForYouTracksRow
|
var items []ListNewForYouTracksRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListNewForYouTracksRow
|
var i ListNewForYouTracksRow
|
||||||
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.AlbumID,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.Tier,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -203,7 +294,10 @@ WITH windowed AS (
|
|||||||
FROM play_events
|
FROM play_events
|
||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
AND started_at < now() - interval '30 days'
|
AND started_at < now() - interval '30 days'
|
||||||
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
|
AND LEAST(
|
||||||
|
ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date)),
|
||||||
|
365 - ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date))
|
||||||
|
) <= 10
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
@@ -235,6 +329,13 @@ type ListOnThisDayTracksRow struct {
|
|||||||
// and window ±7→±10 so it surfaces on a months-old library instead
|
// and window ±7→±10 so it surfaces on a months-old library instead
|
||||||
// of needing a full year of history; still skips cleanly (no rows →
|
// of needing a full year of history; still skips cleanly (no rows →
|
||||||
// no playlist) when there's no qualifying history yet.
|
// no playlist) when there's no qualifying history yet.
|
||||||
|
// Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs
|
||||||
|
// Jan 3 read as 359 days apart, silently gutting the window for ~3
|
||||||
|
// weeks around every New Year — precisely when holiday nostalgia is
|
||||||
|
// the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of
|
||||||
|
// ±1 is absorbed by the ±10 window. The build date ($2, already here
|
||||||
|
// for the md5 rotation) anchors "today" instead of now() so the
|
||||||
|
// window is testable and consistent with the mix's daily determinism.
|
||||||
// $1 user_id, $2 date string.
|
// $1 user_id, $2 date string.
|
||||||
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
|
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
|
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
|
||||||
@@ -262,37 +363,32 @@ WITH stats AS (
|
|||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||||
GROUP BY pe.track_id
|
GROUP BY pe.track_id
|
||||||
),
|
|
||||||
deep AS (
|
|
||||||
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
|
|
||||||
FROM tracks t
|
|
||||||
JOIN stats s ON s.track_id = t.id
|
|
||||||
WHERE s.c >= 5
|
|
||||||
AND s.last_at <= now() - interval '6 months'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
||||||
)
|
|
||||||
),
|
|
||||||
shallow AS (
|
|
||||||
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
|
|
||||||
FROM tracks t
|
|
||||||
JOIN stats s ON s.track_id = t.id
|
|
||||||
WHERE s.c >= 5
|
|
||||||
AND s.last_at <= now() - interval '30 days'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
SELECT id, album_id, artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM (
|
FROM tracks t
|
||||||
SELECT id, album_id, artist_id, c, tier FROM deep
|
JOIN stats s ON s.track_id = t.id
|
||||||
UNION ALL
|
WHERE s.c >= 3
|
||||||
SELECT id, album_id, artist_id, c, tier FROM shallow
|
AND s.last_at <= now() - interval '30 days'
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM deep)
|
AND NOT EXISTS (
|
||||||
) u
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
ORDER BY tier, c DESC, id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
-- Declined-resurfacing cooldown (#1258): a skip on a
|
||||||
|
-- rediscover-sourced play is the user explicitly saying "I've moved
|
||||||
|
-- on" to the exact invitation this mix extends — such tracks sit
|
||||||
|
-- out ~90 days instead of re-qualifying the next day. Passive
|
||||||
|
-- signal only (rule #101).
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM play_events rp
|
||||||
|
WHERE rp.user_id = $1
|
||||||
|
AND rp.track_id = t.id
|
||||||
|
AND rp.source = 'rediscover'
|
||||||
|
AND rp.was_skipped = true
|
||||||
|
AND rp.started_at > now() - interval '90 days'
|
||||||
|
)
|
||||||
|
ORDER BY (s.last_at <= now() - interval '6 months') DESC,
|
||||||
|
(s.c >= 5) DESC,
|
||||||
|
s.c DESC, t.id
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -302,13 +398,17 @@ type ListRediscoverTracksRow struct {
|
|||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
|
// #420 Rediscover: tracks the user played a lot but has drifted away
|
||||||
// has drifted away from. Tiered so a young library still gets a mix:
|
// from. One blended pool (issue #1246: the old two-tier UNION was
|
||||||
//
|
// all-or-nothing — a single ">=6 months cold" row suppressed the whole
|
||||||
// tier 0 not played in the last 6 months (true rediscovery)
|
// ">=30 days" tier, which is how a one-song playlist shipped; the 6mo
|
||||||
// tier 1 (only if tier 0 empty) not played in the last 30 days
|
// tier was also a strict subset of the 30d tier). Eligibility is >=3
|
||||||
//
|
// non-skip plays and >=30 days cold; the bar sits at 3 rather than 5
|
||||||
// Ordered by historical affection. $1 user_id.
|
// so a weeks-old library still fields a pool — on young histories the
|
||||||
|
// >=5-play tracks are precisely the ones still in rotation. Ordering
|
||||||
|
// prefers true rediscoveries (>=6 months cold), then strong affection
|
||||||
|
// (>=5 plays), then raw play count; the producer's minimum floor
|
||||||
|
// decides whether the pool is big enough to ship at all. $1 user_id.
|
||||||
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
|
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
|
rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -290,9 +290,13 @@ func (q *Queries) ListPlaylistsByUserAndKind(ctx context.Context, arg ListPlayli
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pickSeedArtists = `-- name: PickSeedArtists :many
|
const pickSeedArtists = `-- name: PickSeedArtists :many
|
||||||
WITH plays AS (
|
WITH liked AS (
|
||||||
|
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||||
|
),
|
||||||
|
recent7 AS (
|
||||||
SELECT t.artist_id,
|
SELECT t.artist_id,
|
||||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
|
0 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
@@ -300,27 +304,79 @@ WITH plays AS (
|
|||||||
AND t.artist_id IS NOT NULL
|
AND t.artist_id IS NOT NULL
|
||||||
GROUP BY t.artist_id
|
GROUP BY t.artist_id
|
||||||
),
|
),
|
||||||
liked AS (
|
recent30 AS (
|
||||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
SELECT t.artist_id,
|
||||||
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
|
1 AS tier
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.started_at > now() - INTERVAL '30 days'
|
||||||
|
AND t.artist_id IS NOT NULL
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
alltime AS (
|
||||||
|
SELECT t.artist_id,
|
||||||
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
|
2 AS tier
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND t.artist_id IS NOT NULL
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
likedonly AS (
|
||||||
|
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
|
||||||
|
FROM liked l
|
||||||
|
),
|
||||||
|
chosen AS (
|
||||||
|
SELECT artist_id, play_count, tier FROM recent7
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, play_count, tier FROM recent30
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, play_count, tier FROM alltime
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM recent30)
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, play_count, tier FROM likedonly
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM recent30)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM alltime)
|
||||||
)
|
)
|
||||||
SELECT p.artist_id,
|
SELECT c.artist_id,
|
||||||
(p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score
|
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
|
||||||
FROM plays p
|
c.tier::int AS tier
|
||||||
LEFT JOIN liked l ON l.artist_id = p.artist_id
|
FROM chosen c
|
||||||
ORDER BY score DESC, p.artist_id
|
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
||||||
|
ORDER BY score DESC, c.artist_id
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
`
|
`
|
||||||
|
|
||||||
type PickSeedArtistsRow struct {
|
type PickSeedArtistsRow struct {
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
Score int64
|
Score int64
|
||||||
|
Tier int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-5 most-engaged distinct artist candidates in the user's last 7
|
// Top-5 most-engaged distinct artist candidates, tiered so the
|
||||||
// days. The Go-side picker (pickSeedArtistsForDay) shuffles these
|
// "Songs like X" mixes never silently vanish (#1255): the old hard
|
||||||
// daily-deterministically and takes the first 3 so the set of
|
// 7-day window emptied the seed pool after a quiet week, and the
|
||||||
// "Songs like X" mixes rotates day-to-day.
|
// daily atomic-replace build then deleted every existing mix until
|
||||||
// Score = unskipped-play count + 5 if user has liked the artist.
|
// the user played something again. Same fallback shape as
|
||||||
|
// PickTopPlayedTracksForUser (For You's seeds):
|
||||||
|
//
|
||||||
|
// tier 0 engagement in the last 7 days
|
||||||
|
// tier 1 (only if tier 0 empty) last 30 days
|
||||||
|
// tier 2 (only if tiers 0+1 empty) all-time
|
||||||
|
// tier 3 (only if 0-2 empty) liked artists with no play history
|
||||||
|
//
|
||||||
|
// All returned rows share one tier; produceSeedMixes maps it onto the
|
||||||
|
// rule-#131 pick-kind ladder and stamps the built tracks, so metrics
|
||||||
|
// can compare mixes seeded from fresh vs stale engagement.
|
||||||
|
// Score = unskipped-play count + 5 if user has liked the artist. The
|
||||||
|
// Go-side picker (pickSeedArtistsForDay) shuffles the 5
|
||||||
|
// daily-deterministically and takes 3 so the mix set rotates.
|
||||||
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
|
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
|
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -330,7 +386,7 @@ func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]Pi
|
|||||||
var items []PickSeedArtistsRow
|
var items []PickSeedArtistsRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i PickSeedArtistsRow
|
var i PickSeedArtistsRow
|
||||||
if err := rows.Scan(&i.ArtistID, &i.Score); err != nil {
|
if err := rows.Scan(&i.ArtistID, &i.Score, &i.Tier); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -457,10 +513,10 @@ SELECT id
|
|||||||
// tier 2 (only if tiers 0+1 empty) liked tracks
|
// tier 2 (only if tiers 0+1 empty) liked tracks
|
||||||
//
|
//
|
||||||
// Returns up to 5 ids; tie-break by track_id for determinism. The
|
// Returns up to 5 ids; tie-break by track_id for determinism. The
|
||||||
// Go-side picker (pickForYouSeedForDay) rotates one per day via
|
// Go-side picker (pickDailySeeds) draws the day's seeds from these.
|
||||||
// userIDHash. Widened from a hard 7-day window, which made For-You
|
// Widened from a hard 7-day window, which made For-You disappear
|
||||||
// disappear after a week of not listening and never recover on a
|
// after a week of not listening and never recover on a self-hosted
|
||||||
// self-hosted library with sparse history.
|
// library with sparse history.
|
||||||
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||||
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
|
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
|
|||||||
const createUser = `-- name: CreateUser :one
|
const createUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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 {
|
type CreateUserParams struct {
|
||||||
@@ -89,6 +89,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
const createUserAdmin = `-- name: CreateUserAdmin :one
|
const createUserAdmin = `-- name: CreateUserAdmin :one
|
||||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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 {
|
type CreateUserAdminParams struct {
|
||||||
@@ -134,6 +135,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -145,7 +147,7 @@ VALUES (
|
|||||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||||
$4
|
$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 {
|
type CreateUserFirstAdminRaceParams struct {
|
||||||
@@ -191,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -237,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
|
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) {
|
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.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
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
|
// 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.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByID = `-- name: GetUserByID :one
|
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) {
|
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.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
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) {
|
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.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -382,7 +389,8 @@ func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiv
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listUsers = `-- name: ListUsers :many
|
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
|
FROM users
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -393,6 +401,7 @@ type ListUsersRow struct {
|
|||||||
DisplayName *string
|
DisplayName *string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
AutoApproveRequests bool
|
AutoApproveRequests bool
|
||||||
|
DebugModeEnabled bool
|
||||||
CreatedAt pgtype.Timestamptz
|
CreatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +421,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -426,7 +436,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|||||||
|
|
||||||
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
||||||
UPDATE users SET api_token = $2 WHERE id = $1
|
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 {
|
type RegenerateApiTokenParams struct {
|
||||||
@@ -454,6 +464,7 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -478,6 +489,44 @@ func (q *Queries) ResetUserPassword(ctx context.Context, arg ResetUserPasswordPa
|
|||||||
return err
|
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
|
const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET listenbrainz_enabled = $2
|
SET listenbrainz_enabled = $2
|
||||||
@@ -531,7 +580,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_admin = $2
|
SET is_admin = $2
|
||||||
WHERE id = $1
|
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 {
|
type UpdateUserAdminParams struct {
|
||||||
@@ -559,6 +608,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -567,7 +617,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET auto_approve_requests = $2
|
SET auto_approve_requests = $2
|
||||||
WHERE id = $1
|
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 {
|
type UpdateUserAutoApproveParams struct {
|
||||||
@@ -594,6 +644,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -603,7 +654,7 @@ UPDATE users
|
|||||||
SET display_name = $2,
|
SET display_name = $2,
|
||||||
email = $3
|
email = $3
|
||||||
WHERE id = $1
|
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 {
|
type UpdateUserProfileParams struct {
|
||||||
@@ -632,6 +683,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,70 @@ func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMig
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listYouMightLikeAlbumFallbackForUser = `-- name: ListYouMightLikeAlbumFallbackForUser :many
|
||||||
|
WITH liked_album_ids AS (
|
||||||
|
SELECT likb.album_id AS album_id
|
||||||
|
FROM general_likes_albums likb
|
||||||
|
WHERE likb.user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT trk.album_id AS album_id
|
||||||
|
FROM general_likes likt
|
||||||
|
JOIN tracks trk ON trk.id = likt.track_id
|
||||||
|
WHERE likt.user_id = $1
|
||||||
|
)
|
||||||
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||||
|
FROM liked_album_ids lai
|
||||||
|
JOIN albums ON albums.id = lai.album_id
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
ORDER BY md5(albums.id::text || current_date::text)
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListYouMightLikeAlbumFallbackForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListYouMightLikeAlbumFallbackForUserRow struct {
|
||||||
|
Album Album
|
||||||
|
ArtistName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS
|
||||||
|
// the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser.
|
||||||
|
func (q *Queries) ListYouMightLikeAlbumFallbackForUser(ctx context.Context, arg ListYouMightLikeAlbumFallbackForUserParams) ([]ListYouMightLikeAlbumFallbackForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listYouMightLikeAlbumFallbackForUser, arg.UserID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListYouMightLikeAlbumFallbackForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListYouMightLikeAlbumFallbackForUserRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Album.ID,
|
||||||
|
&i.Album.Title,
|
||||||
|
&i.Album.SortTitle,
|
||||||
|
&i.Album.ArtistID,
|
||||||
|
&i.Album.ReleaseDate,
|
||||||
|
&i.Album.Mbid,
|
||||||
|
&i.Album.CoverArtPath,
|
||||||
|
&i.Album.CreatedAt,
|
||||||
|
&i.Album.UpdatedAt,
|
||||||
|
&i.Album.CoverArtSource,
|
||||||
|
&i.Album.CoverArtSourcesVersion,
|
||||||
|
&i.ArtistName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many
|
const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many
|
||||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||||
FROM you_might_like_albums yml
|
FROM you_might_like_albums yml
|
||||||
@@ -149,6 +213,90 @@ func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYou
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listYouMightLikeArtistFallbackForUser = `-- name: ListYouMightLikeArtistFallbackForUser :many
|
||||||
|
WITH liked_artist_ids AS (
|
||||||
|
SELECT lika.artist_id AS artist_id
|
||||||
|
FROM general_likes_artists lika
|
||||||
|
WHERE lika.user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT alb.artist_id AS artist_id
|
||||||
|
FROM general_likes_albums likb
|
||||||
|
JOIN albums alb ON alb.id = likb.album_id
|
||||||
|
WHERE likb.user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT trk.artist_id AS artist_id
|
||||||
|
FROM general_likes likt
|
||||||
|
JOIN tracks trk ON trk.id = likt.track_id
|
||||||
|
WHERE likt.user_id = $1
|
||||||
|
)
|
||||||
|
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||||
|
cov.id AS cover_album_id,
|
||||||
|
cnt.album_count::bigint AS album_count
|
||||||
|
FROM liked_artist_ids lai
|
||||||
|
JOIN artists ON artists.id = lai.artist_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id FROM albums
|
||||||
|
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
) cov ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT count(*) AS album_count
|
||||||
|
FROM albums WHERE artist_id = artists.id
|
||||||
|
) cnt ON true
|
||||||
|
ORDER BY md5(artists.id::text || current_date::text)
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListYouMightLikeArtistFallbackForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListYouMightLikeArtistFallbackForUserRow struct {
|
||||||
|
Artist Artist
|
||||||
|
CoverAlbumID pgtype.UUID
|
||||||
|
AlbumCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broad "artists you've shown affinity for" pool to fill a thin
|
||||||
|
// You-might-like artists row: explicitly liked artists PLUS the artists of
|
||||||
|
// liked albums and liked tracks. Most users like albums/tracks far more than
|
||||||
|
// they like artists explicitly, so the union is much deeper than
|
||||||
|
// general_likes_artists alone. Daily-stable order; same projection as
|
||||||
|
// ListYouMightLikeArtistsForUser so the Go layer reuses it directly.
|
||||||
|
func (q *Queries) ListYouMightLikeArtistFallbackForUser(ctx context.Context, arg ListYouMightLikeArtistFallbackForUserParams) ([]ListYouMightLikeArtistFallbackForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listYouMightLikeArtistFallbackForUser, arg.UserID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListYouMightLikeArtistFallbackForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListYouMightLikeArtistFallbackForUserRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Artist.ID,
|
||||||
|
&i.Artist.Name,
|
||||||
|
&i.Artist.SortName,
|
||||||
|
&i.Artist.Mbid,
|
||||||
|
&i.Artist.CreatedAt,
|
||||||
|
&i.Artist.UpdatedAt,
|
||||||
|
&i.Artist.ArtistThumbPath,
|
||||||
|
&i.Artist.ArtistFanartPath,
|
||||||
|
&i.Artist.ArtistArtSource,
|
||||||
|
&i.Artist.ArtistArtSourcesVersion,
|
||||||
|
&i.CoverAlbumID,
|
||||||
|
&i.AlbumCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many
|
const listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many
|
||||||
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||||
cov.id AS cover_album_id,
|
cov.id AS cover_album_id,
|
||||||
|
|||||||
@@ -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,2 @@
|
|||||||
|
ALTER TABLE play_events DROP COLUMN pick_kind;
|
||||||
|
ALTER TABLE playlist_tracks DROP COLUMN pick_kind;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- For You exploration attribution (milestone #127 step 2, Scribe #1249).
|
||||||
|
--
|
||||||
|
-- For You is deliberately composed of two populations: a head of
|
||||||
|
-- top-scored similarity matches ("taste" picks) and a tail sampled from
|
||||||
|
-- deeper in the candidate ranking ("fresh" picks — the freshness
|
||||||
|
-- injection). Until now the metrics could only judge For You as one
|
||||||
|
-- blob, so a high skip rate couldn't be read as "the taste engine is
|
||||||
|
-- missing" vs "the freshness tax is too high".
|
||||||
|
--
|
||||||
|
-- Two denormalized columns, both nullable:
|
||||||
|
--
|
||||||
|
-- 1. playlist_tracks.pick_kind — stamped by the system-playlist builder
|
||||||
|
-- at snapshot build time. NULL for manual playlists and for system
|
||||||
|
-- variants that don't split (Discover, Songs like X, discovery
|
||||||
|
-- mixes). System snapshots are atomically replaced daily, so the
|
||||||
|
-- column never goes stale within a snapshot.
|
||||||
|
--
|
||||||
|
-- 2. play_events.pick_kind — stamped at play-ingestion time by looking
|
||||||
|
-- the track up in the user's CURRENT For You snapshot. Attribution
|
||||||
|
-- must be frozen at play time: For You rebuilds daily, so a 30-day
|
||||||
|
-- metrics window spans ~30 snapshots and a join-at-read against the
|
||||||
|
-- live snapshot would misattribute nearly everything. Plays that
|
||||||
|
-- predate this feature (or whose track already rotated out, e.g. a
|
||||||
|
-- late offline replay) stay NULL → reported as unattributed.
|
||||||
|
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
ADD COLUMN pick_kind text
|
||||||
|
CONSTRAINT playlist_tracks_pick_kind_check
|
||||||
|
CHECK (pick_kind IN ('taste', 'fresh'));
|
||||||
|
|
||||||
|
ALTER TABLE play_events
|
||||||
|
ADD COLUMN pick_kind text
|
||||||
|
CONSTRAINT play_events_pick_kind_check
|
||||||
|
CHECK (pick_kind IN ('taste', 'fresh'));
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user